├── app ├── .gitkeep ├── serializers │ ├── user_score_serializer.rb │ ├── admin_gamification_score_event_serializer.rb │ ├── admin_gamification_score_event_index_serializer.rb │ ├── leaderboard_serializer.rb │ ├── admin_gamification_index_serializer.rb │ └── leaderboard_view_serializer.rb ├── models │ └── discourse_gamification │ │ ├── deleted_gamification_leaderboard.rb │ │ ├── gamification_score_event.rb │ │ ├── gamification_leaderboard.rb │ │ └── gamification_score.rb └── controllers │ └── discourse_gamification │ ├── gamification_leaderboard_controller.rb │ └── admin_gamification_score_event_controller.rb ├── db ├── .gitkeep ├── migrate │ ├── 20220315172912_add_score_to_directory_items.rb │ ├── 20221019171131_add_default_period_to_leaderboards.rb │ ├── 20250102185307_add_period_filter_disabled_to_leaderboards.rb │ ├── 20220623182333_add_excluded_groups_to_leaderboards.rb │ ├── 20220314190045_create_gamification_score_table.rb │ ├── 20220324210218_create_gamification_leaderboard_table.rb │ ├── 20220331203401_add_groups_to_leaderboards.rb │ └── 20230420185415_create_gamification_score_events.rb ├── fixtures │ └── 001_gamification_leaderboards.rb └── post_migrate │ └── 20250210133038_drop_versioned_leaderboard_materialized_views.rb ├── .gitignore ├── .npmrc ├── .streerc ├── .rubocop.yml ├── .prettierrc.cjs ├── .template-lintrc.cjs ├── stylelint.config.mjs ├── assets ├── stylesheets │ ├── common │ │ ├── gamification-score.scss │ │ ├── leaderboard-info-modal.scss │ │ ├── leaderboard-admin.scss │ │ └── leaderboard-minimal.scss │ ├── desktop │ │ └── leaderboard.scss │ └── mobile │ │ └── leaderboard.scss └── javascripts │ ├── discourse │ ├── helpers │ │ ├── sum.js │ │ └── fullnumber.js │ ├── gamification-route-map.js │ ├── templates │ │ ├── gamification-leaderboard-by-name.gjs │ │ └── gamification-leaderboard-index.gjs │ ├── admin-discourse-gamification-plugin-route-map.js │ ├── routes │ │ ├── gamification-leaderboard-index.js │ │ └── gamification-leaderboard-by-name.js │ ├── connectors │ │ ├── user-profile-secondary │ │ │ └── gamification-score.gjs │ │ └── user-card-metadata │ │ │ └── gamification-score.gjs │ ├── components │ │ ├── modal │ │ │ └── leaderboard-info.gjs │ │ ├── gamification-score.gjs │ │ ├── period-input.js │ │ ├── gamification-leaderboard-row.gjs │ │ ├── minimal-gamification-leaderboard-row.gjs │ │ └── minimal-gamification-leaderboard.gjs │ └── models │ │ └── gamification-leaderboard.js │ └── initializers │ └── admin-plugin-configuration-nav.js ├── eslint.config.mjs ├── about.json ├── Gemfile ├── spec ├── system │ ├── core_features_spec.rb │ ├── page_objects │ │ ├── modals │ │ │ └── recalculate_scores_form.rb │ │ └── pages │ │ │ └── admin_leaderboards.rb │ └── recalculate_scores_form_spec.rb ├── fabricators │ ├── gamification_score.rb │ └── gamification_leaderboard.rb ├── jobs │ ├── recalculate_scores_spec.rb │ ├── generate_leaderboard_positions_spec.rb │ ├── refresh_leaderboard_positions_spec.rb │ ├── delete_leaderboard_positions_spec.rb │ ├── update_scores_for_ten_days_spec.rb │ └── update_stale_leaderboard_positions_spec.rb ├── models │ ├── user_spec.rb │ ├── gamification_score_spec.rb │ └── gamification_leaderboard_spec.rb ├── lib │ ├── scorables │ │ └── solutions_spec.rb │ └── directory_integration_spec.rb └── plugin_spec.rb ├── lib ├── discourse_gamification │ ├── engine.rb │ ├── scorables │ │ ├── scorable.rb │ │ ├── day_visited.rb │ │ ├── flag_created.rb │ │ ├── post_read.rb │ │ ├── time_read.rb │ │ ├── user_invited.rb │ │ ├── topic_created.rb │ │ ├── chat_reaction_given.rb │ │ ├── chat_reaction_received.rb │ │ ├── like_given.rb │ │ ├── like_received.rb │ │ ├── post_created.rb │ │ ├── chat_message_created.rb │ │ ├── reaction_given.rb │ │ ├── reaction_received.rb │ │ └── solutions.rb │ ├── guardian_extension.rb │ ├── recalculate_scores_rate_limiter.rb │ ├── user_extension.rb │ └── directory_integration.rb └── tasks │ └── gamification_scores.rake ├── config ├── locales │ ├── server.be.yml │ ├── server.bg.yml │ ├── server.ca.yml │ ├── server.da.yml │ ├── server.el.yml │ ├── server.et.yml │ ├── server.gl.yml │ ├── server.hy.yml │ ├── server.id.yml │ ├── server.ko.yml │ ├── server.lt.yml │ ├── server.lv.yml │ ├── server.pt.yml │ ├── server.ro.yml │ ├── server.sk.yml │ ├── server.sl.yml │ ├── server.sq.yml │ ├── server.sr.yml │ ├── server.sw.yml │ ├── server.te.yml │ ├── server.th.yml │ ├── server.ug.yml │ ├── server.uk.yml │ ├── server.ur.yml │ ├── server.vi.yml │ ├── client.en_GB.yml │ ├── server.bs_BA.yml │ ├── server.en_GB.yml │ ├── server.nb_NO.yml │ ├── server.pl_PL.yml │ ├── server.zh_TW.yml │ ├── server.fa_IR.yml │ ├── client.id.yml │ ├── client.sq.yml │ ├── client.bs_BA.yml │ ├── client.be.yml │ ├── client.ug.yml │ ├── client.te.yml │ ├── client.gl.yml │ ├── client.ko.yml │ ├── client.et.yml │ ├── client.sw.yml │ ├── client.zh_TW.yml │ ├── client.hy.yml │ ├── client.sr.yml │ ├── client.th.yml │ ├── client.ur.yml │ ├── client.da.yml │ ├── client.vi.yml │ ├── client.sl.yml │ ├── client.lv.yml │ ├── client.nb_NO.yml │ ├── client.bg.yml │ ├── client.pt.yml │ ├── client.ca.yml │ ├── client.pl_PL.yml │ ├── client.ro.yml │ ├── client.el.yml │ ├── client.sk.yml │ ├── client.lt.yml │ ├── client.uk.yml │ ├── server.zh_CN.yml │ ├── server.sv.yml │ ├── server.hr.yml │ ├── server.ja.yml │ ├── client.fa_IR.yml │ ├── client.hr.yml │ ├── server.he.yml │ ├── server.ar.yml │ ├── server.en.yml │ ├── server.ru.yml │ ├── server.cs.yml │ ├── server.es.yml │ ├── server.fi.yml │ ├── server.hu.yml │ ├── client.sv.yml │ ├── server.tr_TR.yml │ ├── client.zh_CN.yml │ ├── server.pt_BR.yml │ ├── server.it.yml │ ├── server.fr.yml │ ├── client.ja.yml │ ├── server.de.yml │ └── server.nl.yml ├── settings.yml └── routes.rb ├── translator.yml ├── .github └── workflows │ └── discourse-plugin.yml ├── .git-blame-ignore-revs ├── jobs ├── regular │ ├── regenerate_leaderboard_positions.rb │ ├── update_stale_leaderboard_positions.rb │ ├── refresh_leaderboard_positions.rb │ ├── delete_leaderboard_positions.rb │ ├── generate_leaderboard_positions.rb │ └── recalculate_scores.rb └── scheduled │ ├── update_scores_for_ten_days.rb │ └── update_scores_for_today.rb ├── package.json ├── .discourse-compatibility ├── admin └── assets │ └── javascripts │ ├── discourse │ ├── templates │ │ └── admin-plugins │ │ │ └── show │ │ │ └── discourse-gamification-leaderboards │ │ │ └── show.gjs │ ├── routes │ │ ├── admin-plugins-show-discourse-gamification-leaderboards-show.js │ │ └── admin-plugins-show-discourse-gamification-leaderboards.js │ └── controllers │ │ └── admin-plugins-show-discourse-gamification-leaderboards-index.js │ └── admin │ └── components │ └── admin-create-leaderboard.gjs ├── README.md ├── LICENSE └── test └── javascripts ├── components ├── gamification-score-test.gjs ├── minimal-gamification-leaderboard-test.gjs ├── gamification-leaderboard-row-test.gjs ├── minimal-gamification-leaderboard-row-test.gjs └── gamification-leaderboard-test.gjs └── acceptance └── gamification-score-test.js /app/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /db/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /gems 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict = true 2 | auto-install-peers = false 3 | -------------------------------------------------------------------------------- /.streerc: -------------------------------------------------------------------------------- 1 | --print-width=100 2 | --plugins=plugin/trailing_comma 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | rubocop-discourse: stree-compat.yml 3 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("@discourse/lint-configs/prettier"); 2 | -------------------------------------------------------------------------------- /.template-lintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("@discourse/lint-configs/template-lint"); 2 | -------------------------------------------------------------------------------- /stylelint.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ["@discourse/lint-configs/stylelint"], 3 | }; 4 | -------------------------------------------------------------------------------- /assets/stylesheets/common/gamification-score.scss: -------------------------------------------------------------------------------- 1 | h3 { 2 | a.gamification-score__link { 3 | color: var(--tertiary); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import DiscourseRecommended from "@discourse/lint-configs/eslint"; 2 | 3 | export default [...DiscourseRecommended]; 4 | -------------------------------------------------------------------------------- /about.json: -------------------------------------------------------------------------------- 1 | { 2 | "tests": { 3 | "requiredPlugins": [ 4 | "https://github.com/discourse/discourse-solved" 5 | ] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | group :development do 6 | gem "rubocop-discourse" 7 | gem "syntax_tree" 8 | end 9 | -------------------------------------------------------------------------------- /app/serializers/user_score_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UserScoreSerializer < BasicUserSerializer 4 | attributes :total_score, :position 5 | end 6 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/helpers/sum.js: -------------------------------------------------------------------------------- 1 | import Helper from "@ember/component/helper"; 2 | 3 | export function sum(params) { 4 | return params[0] + params[1]; 5 | } 6 | 7 | export default Helper.helper(sum); 8 | -------------------------------------------------------------------------------- /spec/system/core_features_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "Core features", type: :system do 4 | before { enable_current_plugin } 5 | 6 | it_behaves_like "having working core features" 7 | end 8 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/gamification-route-map.js: -------------------------------------------------------------------------------- 1 | export default function () { 2 | this.route("gamificationLeaderboard", { path: "/leaderboard" }, function () { 3 | this.route("byName", { path: "/:leaderboardId" }); 4 | }); 5 | } 6 | -------------------------------------------------------------------------------- /lib/discourse_gamification/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseGamification 4 | class Engine < ::Rails::Engine 5 | engine_name PLUGIN_NAME 6 | isolate_namespace DiscourseGamification 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /config/locales/server.be.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | be: 8 | -------------------------------------------------------------------------------- /config/locales/server.bg.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | bg: 8 | -------------------------------------------------------------------------------- /config/locales/server.ca.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ca: 8 | -------------------------------------------------------------------------------- /config/locales/server.da.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | da: 8 | -------------------------------------------------------------------------------- /config/locales/server.el.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | el: 8 | -------------------------------------------------------------------------------- /config/locales/server.et.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | et: 8 | -------------------------------------------------------------------------------- /config/locales/server.gl.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | gl: 8 | -------------------------------------------------------------------------------- /config/locales/server.hy.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hy: 8 | -------------------------------------------------------------------------------- /config/locales/server.id.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | id: 8 | -------------------------------------------------------------------------------- /config/locales/server.ko.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ko: 8 | -------------------------------------------------------------------------------- /config/locales/server.lt.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | lt: 8 | -------------------------------------------------------------------------------- /config/locales/server.lv.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | lv: 8 | -------------------------------------------------------------------------------- /config/locales/server.pt.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | pt: 8 | -------------------------------------------------------------------------------- /config/locales/server.ro.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ro: 8 | -------------------------------------------------------------------------------- /config/locales/server.sk.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sk: 8 | -------------------------------------------------------------------------------- /config/locales/server.sl.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sl: 8 | -------------------------------------------------------------------------------- /config/locales/server.sq.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sq: 8 | -------------------------------------------------------------------------------- /config/locales/server.sr.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sr: 8 | -------------------------------------------------------------------------------- /config/locales/server.sw.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sw: 8 | -------------------------------------------------------------------------------- /config/locales/server.te.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | te: 8 | -------------------------------------------------------------------------------- /config/locales/server.th.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | th: 8 | -------------------------------------------------------------------------------- /config/locales/server.ug.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ug: 8 | -------------------------------------------------------------------------------- /config/locales/server.uk.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | uk: 8 | -------------------------------------------------------------------------------- /config/locales/server.ur.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ur: 8 | -------------------------------------------------------------------------------- /config/locales/server.vi.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | vi: 8 | -------------------------------------------------------------------------------- /translator.yml: -------------------------------------------------------------------------------- 1 | # Configuration file for discourse-translator-bot 2 | 3 | files: 4 | - source_path: config/locales/client.en.yml 5 | destination_path: client.yml 6 | - source_path: config/locales/server.en.yml 7 | destination_path: server.yml 8 | -------------------------------------------------------------------------------- /.github/workflows/discourse-plugin.yml: -------------------------------------------------------------------------------- 1 | name: Discourse Plugin 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | ci: 11 | uses: discourse/.github/.github/workflows/discourse-plugin.yml@v1 12 | -------------------------------------------------------------------------------- /spec/fabricators/gamification_score.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Fabricator(:gamification_score, from: ::DiscourseGamification::GamificationScore) do 4 | user_id { Fabricate(:user).id } 5 | score { 0 } 6 | date { Date.today } 7 | end 8 | -------------------------------------------------------------------------------- /config/locales/client.en_GB.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | en_GB: 8 | -------------------------------------------------------------------------------- /config/locales/server.bs_BA.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | bs_BA: 8 | -------------------------------------------------------------------------------- /config/locales/server.en_GB.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | en_GB: 8 | -------------------------------------------------------------------------------- /config/locales/server.nb_NO.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | nb_NO: 8 | -------------------------------------------------------------------------------- /config/locales/server.pl_PL.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | pl_PL: 8 | -------------------------------------------------------------------------------- /config/locales/server.zh_TW.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | zh_TW: 8 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Only add no-op commits to this file 2 | # To prevent these commits to show in git blame 3 | # git config blame.ignoreRevsFile .git-blame-ignore-revs 4 | 5 | # DEV: Apply syntax_tree formatting 6 | 7f9d4cbaebb494d8d43fbaef2d9bc17133716b4a 7 | -------------------------------------------------------------------------------- /app/serializers/admin_gamification_score_event_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AdminGamificationScoreEventSerializer < ApplicationSerializer 4 | attributes :id, :user_id, :date, :points, :description, :created_at, :updated_at 5 | end 6 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/helpers/fullnumber.js: -------------------------------------------------------------------------------- 1 | import Helper from "@ember/component/helper"; 2 | import I18n from "discourse-i18n"; 3 | 4 | export function fullnumber(number) { 5 | return I18n.toNumber(number, { precision: 0 }); 6 | } 7 | 8 | export default Helper.helper(fullnumber); 9 | -------------------------------------------------------------------------------- /app/models/discourse_gamification/deleted_gamification_leaderboard.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseGamification 4 | class DeletedGamificationLeaderboard 5 | attr_reader :id 6 | 7 | def initialize(id) 8 | @id = id 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /jobs/regular/regenerate_leaderboard_positions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jobs 4 | class RegenerateLeaderboardPositions < ::Jobs::Base 5 | def execute(args = nil) 6 | DiscourseGamification::LeaderboardCachedView.regenerate_all 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /jobs/regular/update_stale_leaderboard_positions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jobs 4 | class UpdateStaleLeaderboardPositions < ::Jobs::Base 5 | def execute(args = nil) 6 | DiscourseGamification::LeaderboardCachedView.update_all 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /assets/stylesheets/common/leaderboard-info-modal.scss: -------------------------------------------------------------------------------- 1 | .leaderboard-info-modal { 2 | .d-modal__body { 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | } 7 | 8 | .d-icon-award { 9 | margin-right: 2rem; 10 | font-size: 4rem; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/templates/gamification-leaderboard-by-name.gjs: -------------------------------------------------------------------------------- 1 | import RouteTemplate from "ember-route-template"; 2 | import GamificationLeaderboard from "../components/gamification-leaderboard"; 3 | 4 | export default RouteTemplate( 5 | 6 | ); 7 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/templates/gamification-leaderboard-index.gjs: -------------------------------------------------------------------------------- 1 | import RouteTemplate from "ember-route-template"; 2 | import GamificationLeaderboard from "../components/gamification-leaderboard"; 3 | 4 | export default RouteTemplate( 5 | 6 | ); 7 | -------------------------------------------------------------------------------- /app/serializers/admin_gamification_score_event_index_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AdminGamificationScoreEventIndexSerializer < ApplicationSerializer 4 | has_many :events, serializer: AdminGamificationScoreEventSerializer, embed: :objects 5 | 6 | def events 7 | object[:events] 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /jobs/scheduled/update_scores_for_ten_days.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jobs 4 | class UpdateScoresForTenDays < ::Jobs::Scheduled 5 | every 1.day 6 | 7 | def execute(args = nil) 8 | DiscourseGamification::GamificationScore.calculate_scores(since_date: 10.days.ago.midnight) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20220315172912_add_score_to_directory_items.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class AddScoreToDirectoryItems < ActiveRecord::Migration[6.1] 3 | def up 4 | add_column :directory_items, :gamification_score, :integer, default: 0 5 | end 6 | 7 | def down 8 | remove_column :directory_items, :gamification_score 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/fixtures/001_gamification_leaderboards.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | return if Rails.env.test? || DiscourseGamification::GamificationLeaderboard.any? 4 | 5 | DiscourseGamification::GamificationLeaderboard.seed(:name) do |leaderboard| 6 | leaderboard.name = I18n.t("default_leaderboard_name") 7 | leaderboard.created_by_id = Discourse.system_user.id 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20221019171131_add_default_period_to_leaderboards.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class AddDefaultPeriodToLeaderboards < ActiveRecord::Migration[6.1] 3 | def up 4 | add_column :gamification_leaderboards, :default_period, :integer, default: 0 5 | end 6 | 7 | def down 8 | remove_column :gamification_leaderboards, :default_period 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/discourse_gamification/scorables/scorable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module ::DiscourseGamification 3 | class Scorable 4 | class << self 5 | def enabled? 6 | score_multiplier > 0 7 | end 8 | 9 | def scorable_category_list 10 | SiteSetting.scorable_categories.split("|").map { _1.to_i }.join(", ") 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/admin-discourse-gamification-plugin-route-map.js: -------------------------------------------------------------------------------- 1 | export default { 2 | resource: "admin.adminPlugins.show", 3 | 4 | path: "/plugins", 5 | 6 | map() { 7 | this.route( 8 | "discourse-gamification-leaderboards", 9 | { path: "leaderboards" }, 10 | function () { 11 | this.route("show", { path: "/:id" }); 12 | } 13 | ); 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /db/migrate/20250102185307_add_period_filter_disabled_to_leaderboards.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddPeriodFilterDisabledToLeaderboards < ActiveRecord::Migration[7.2] 4 | def change 5 | add_column :gamification_leaderboards, 6 | :period_filter_disabled, 7 | :boolean, 8 | default: false, 9 | null: false 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20220623182333_add_excluded_groups_to_leaderboards.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class AddExcludedGroupsToLeaderboards < ActiveRecord::Migration[6.1] 3 | def change 4 | add_column :gamification_leaderboards, 5 | :excluded_groups_ids, 6 | :integer, 7 | array: true, 8 | null: false, 9 | default: [] 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /config/locales/server.fa_IR.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | fa_IR: 8 | site_settings: 9 | discourse_gamification_enabled: "فعال کردن افزونه بازی‌وارسازی دیسکورس" 10 | score: "امتیازات" 11 | default_leaderboard_name: "امتیازات سراسری" 12 | -------------------------------------------------------------------------------- /spec/fabricators/gamification_leaderboard.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Fabricator(:gamification_leaderboard, from: ::DiscourseGamification::GamificationLeaderboard) do 4 | name { sequence(:name) { |i| "leaderboard#{i + 1}" } } 5 | created_by_id { Fabricate(:user).id } 6 | from_date { nil } 7 | to_date { nil } 8 | visible_to_groups_ids { [] } 9 | included_groups_ids { [] } 10 | default_period { 0 } 11 | end 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "devDependencies": { 4 | "@discourse/lint-configs": "2.23.0", 5 | "ember-template-lint": "7.7.0", 6 | "eslint": "9.28.0", 7 | "prettier": "3.5.3", 8 | "stylelint": "16.20.0" 9 | }, 10 | "engines": { 11 | "node": ">= 22", 12 | "npm": "please-use-pnpm", 13 | "yarn": "please-use-pnpm", 14 | "pnpm": "9.x" 15 | }, 16 | "packageManager": "pnpm@9.15.5" 17 | } 18 | -------------------------------------------------------------------------------- /.discourse-compatibility: -------------------------------------------------------------------------------- 1 | < 3.5.0.beta5-dev: 0a99016b1ea85b3237609bc1512a10fc5a4fc381 2 | < 3.5.0.beta3-dev: 30b06f2901b6f552ade3cef80af463dc1a2abe7e 3 | < 3.5.0.beta1-dev: 1db5217be8bdf83facd266b590bee1b66b0d2140 4 | < 3.4.0.beta4-dev: 702306826a6d9b860af3a12bc9ce4beee0093650 5 | < 3.4.0.beta2-dev: 6936b01131bc2865d87bde257fe381195743b4f2 6 | < 3.4.0.beta1-dev: 4abb818a6b511878885bb594593dd35e76e1fc08 7 | < 3.3.0.beta1-dev: 5951fc573702090c0dc95b12d4aa3a053303bd63 8 | -------------------------------------------------------------------------------- /lib/discourse_gamification/guardian_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseGamification 4 | module GuardianExtension 5 | def can_see_leaderboard?(leaderboard) 6 | return true if leaderboard.visible_to_groups_ids.empty? 7 | return true if self.is_admin? 8 | return true if self.user && !(leaderboard.visible_to_groups_ids & self.user.group_ids).empty? 9 | 10 | false 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-gamification-leaderboards/show.gjs: -------------------------------------------------------------------------------- 1 | import RouteTemplate from "ember-route-template"; 2 | import AdminEditLeaderboard from "discourse/plugins/discourse-gamification/admin/components/admin-edit-leaderboard"; 3 | 4 | export default RouteTemplate( 5 | 10 | ); 11 | -------------------------------------------------------------------------------- /db/migrate/20220314190045_create_gamification_score_table.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class CreateGamificationScoreTable < ActiveRecord::Migration[6.1] 3 | def change 4 | create_table :gamification_scores do |t| 5 | t.integer :user_id, null: false 6 | t.date :date, null: false 7 | t.integer :score, null: false 8 | end 9 | 10 | add_index :gamification_scores, %i[user_id date], unique: true 11 | add_index :gamification_scores, :date 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/serializers/leaderboard_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class LeaderboardSerializer < ApplicationSerializer 4 | attributes :id, 5 | :name, 6 | :created_by_id, 7 | :from_date, 8 | :to_date, 9 | :visible_to_groups_ids, 10 | :included_groups_ids, 11 | :excluded_groups_ids, 12 | :default_period, 13 | :updated_at, 14 | :period_filter_disabled 15 | end 16 | -------------------------------------------------------------------------------- /lib/tasks/gamification_scores.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | desc "backfill gamification scores from passed day to today" 4 | task "gamification_scores:backfill_scores_from", [:date] => [:environment] do |_, args| 5 | date = args[:date] 6 | if !date 7 | puts "ERROR: Expecting rake gamification_scores:backfill_scores_from[2021-04-01]" 8 | exit 1 9 | end 10 | 11 | DiscourseGamification::GamificationScore.calculate_scores(since_date: date) 12 | puts "Scores updated" 13 | end 14 | -------------------------------------------------------------------------------- /jobs/scheduled/update_scores_for_today.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jobs 4 | class UpdateScoresForToday < ::Jobs::Scheduled 5 | every 1.hour 6 | 7 | def execute(args = nil) 8 | DiscourseGamification::GamificationScore.calculate_scores 9 | 10 | DiscourseGamification::LeaderboardCachedView.purge_all_stale 11 | DiscourseGamification::LeaderboardCachedView.refresh_all 12 | DiscourseGamification::LeaderboardCachedView.create_all 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/routes/gamification-leaderboard-index.js: -------------------------------------------------------------------------------- 1 | import { service } from "@ember/service"; 2 | import { ajax } from "discourse/lib/ajax"; 3 | import DiscourseRoute from "discourse/routes/discourse"; 4 | 5 | export default class GamificationLeaderboardIndex extends DiscourseRoute { 6 | @service router; 7 | 8 | model() { 9 | return ajax(`/leaderboard`) 10 | .then((response) => { 11 | return response; 12 | }) 13 | .catch(() => this.router.replaceWith("/404")); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/connectors/user-profile-secondary/gamification-score.gjs: -------------------------------------------------------------------------------- 1 | import { i18n } from "discourse-i18n"; 2 | import GamificationScore from "../../components/gamification-score"; 3 | 4 | const GamificationScoreConnector = ; 16 | 17 | export default GamificationScoreConnector; 18 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/routes/gamification-leaderboard-by-name.js: -------------------------------------------------------------------------------- 1 | import { service } from "@ember/service"; 2 | import { ajax } from "discourse/lib/ajax"; 3 | import DiscourseRoute from "discourse/routes/discourse"; 4 | 5 | export default class GamificationLeaderboardByName extends DiscourseRoute { 6 | @service router; 7 | 8 | model(params) { 9 | return ajax(`/leaderboard/${params.leaderboardId}`) 10 | .then((response) => { 11 | return response; 12 | }) 13 | .catch(() => this.router.replaceWith("/404")); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /jobs/regular/refresh_leaderboard_positions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jobs 4 | class RefreshLeaderboardPositions < ::Jobs::Base 5 | def execute(args) 6 | leaderboard_id = args[:leaderboard_id] 7 | raise Discourse::InvalidParameters.new(:leaderboard_id) if leaderboard_id.blank? 8 | 9 | leaderboard = DiscourseGamification::GamificationLeaderboard.find_by(id: leaderboard_id) 10 | return unless leaderboard 11 | 12 | DiscourseGamification::LeaderboardCachedView.new(leaderboard).refresh 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20220324210218_create_gamification_leaderboard_table.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class CreateGamificationLeaderboardTable < ActiveRecord::Migration[6.1] 3 | def change 4 | create_table :gamification_leaderboards do |t| 5 | t.string :name, null: false 6 | t.date :from_date, null: true 7 | t.date :to_date, null: true 8 | t.integer :for_category_id, null: true 9 | t.integer :created_by_id, null: false 10 | t.timestamps 11 | end 12 | 13 | add_index :gamification_leaderboards, [:name], unique: true 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/discourse_gamification/recalculate_scores_rate_limiter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DiscourseGamification 4 | class RecalculateScoresRateLimiter 5 | def self.perform! 6 | new.perform! 7 | end 8 | 9 | def self.remaining 10 | new.remaining 11 | end 12 | 13 | def initialize 14 | @rate_limiter = RateLimiter.new(nil, "recalculate_scores_remaining", 5, 24.hours) 15 | end 16 | 17 | def perform! 18 | @rate_limiter.performed! 19 | end 20 | 21 | def remaining 22 | @rate_limiter.remaining 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /db/migrate/20220331203401_add_groups_to_leaderboards.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | class AddGroupsToLeaderboards < ActiveRecord::Migration[6.1] 3 | def change 4 | add_column :gamification_leaderboards, 5 | :visible_to_groups_ids, 6 | :integer, 7 | array: true, 8 | null: false, 9 | default: [] 10 | add_column :gamification_leaderboards, 11 | :included_groups_ids, 12 | :integer, 13 | array: true, 14 | null: false, 15 | default: [] 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/20230420185415_create_gamification_score_events.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateGamificationScoreEvents < ActiveRecord::Migration[7.0] 4 | def change 5 | create_table :gamification_score_events do |t| 6 | t.integer :user_id, null: false 7 | t.date :date, null: false 8 | t.integer :points, null: false 9 | t.text :description, null: true 10 | 11 | t.timestamps 12 | end 13 | 14 | add_index :gamification_score_events, %i[user_id date], unique: false 15 | add_index :gamification_score_events, %i[date], unique: false 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /jobs/regular/delete_leaderboard_positions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jobs 4 | class DeleteLeaderboardPositions < ::Jobs::Base 5 | def execute(args) 6 | leaderboard_id = args[:leaderboard_id] 7 | raise Discourse::InvalidParameters.new(:leaderboard_id) if leaderboard_id.blank? 8 | 9 | leaderboard = 10 | DiscourseGamification::GamificationLeaderboard.find_by(id: leaderboard_id) || 11 | DiscourseGamification::DeletedGamificationLeaderboard.new(leaderboard_id) 12 | 13 | DiscourseGamification::LeaderboardCachedView.new(leaderboard).delete 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /assets/stylesheets/desktop/leaderboard.scss: -------------------------------------------------------------------------------- 1 | .leaderboard { 2 | .podium { 3 | width: 60%; 4 | margin-left: auto; 5 | margin-right: auto; 6 | } 7 | 8 | .winner { 9 | width: 23%; 10 | 11 | &__name { 12 | font-size: var(--font-up-1); 13 | } 14 | 15 | &__avatar { 16 | img { 17 | border-width: 4px; 18 | } 19 | } 20 | 21 | &.-position1 { 22 | width: 30%; 23 | } 24 | } 25 | 26 | .ranking { 27 | width: 75%; 28 | margin-left: auto; 29 | margin-right: auto; 30 | } 31 | 32 | .ranking-col-names { 33 | padding: 1rem 1.5rem 0.25rem 1.5rem; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lib/discourse_gamification/scorables/day_visited.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseGamification 4 | class DayVisited < Scorable 5 | def self.score_multiplier 6 | SiteSetting.day_visited_score_value 7 | end 8 | 9 | def self.query 10 | <<~SQL 11 | SELECT 12 | uv.user_id AS user_id, 13 | date_trunc('day', uv.visited_at) AS date, 14 | COUNT(*) * #{score_multiplier} AS points 15 | FROM 16 | user_visits AS uv 17 | WHERE 18 | uv.visited_at >= :since 19 | GROUP BY 20 | 1, 2 21 | SQL 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/modal/leaderboard-info.gjs: -------------------------------------------------------------------------------- 1 | import { htmlSafe } from "@ember/template"; 2 | import DModal from "discourse/components/d-modal"; 3 | import icon from "discourse/helpers/d-icon"; 4 | import { i18n } from "discourse-i18n"; 5 | 6 | const LeaderboardInfo = ; 18 | 19 | export default LeaderboardInfo; 20 | -------------------------------------------------------------------------------- /app/serializers/admin_gamification_index_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AdminGamificationIndexSerializer < ApplicationSerializer 4 | attribute :gamification_recalculate_scores_remaining 5 | has_many :gamification_leaderboards, serializer: LeaderboardSerializer, embed: :objects 6 | has_many :gamification_groups, serializer: BasicGroupSerializer, embed: :object 7 | 8 | def gamification_leaderboards 9 | object[:leaderboards] 10 | end 11 | 12 | def gamification_groups 13 | Group.all 14 | end 15 | 16 | def gamification_recalculate_scores_remaining 17 | DiscourseGamification::RecalculateScoresRateLimiter.remaining 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/discourse_gamification/scorables/flag_created.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseGamification 4 | class FlagCreated < Scorable 5 | def self.score_multiplier 6 | SiteSetting.flag_created_score_value 7 | end 8 | 9 | def self.query 10 | <<~SQL 11 | SELECT 12 | r.created_by_id AS user_id, 13 | date_trunc('day', r.created_at) AS date, 14 | COUNT(*) * #{score_multiplier} AS points 15 | FROM 16 | reviewables AS r 17 | WHERE 18 | created_at >= :since AND 19 | status = 1#{" "} 20 | GROUP BY 21 | 1, 2 22 | SQL 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/jobs/recalculate_scores_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe Jobs::RecalculateScores do 6 | fab!(:current_user) { Fabricate(:admin) } 7 | 8 | before { RateLimiter.enable } 9 | 10 | it "publishes MessageBus and executes job" do 11 | since = 10.days.ago 12 | DiscourseGamification::GamificationScore.expects(:calculate_scores).with(since_date: since) 13 | 14 | MessageBus 15 | .expects(:publish) 16 | .with("/recalculate_scores", { success: true, remaining: 5, user_id: [current_user.id] }) 17 | .once 18 | Jobs::RecalculateScores.new.execute({ since: since, user_id: current_user.id }) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/discourse_gamification/scorables/post_read.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseGamification 4 | class PostRead < Scorable 5 | def self.score_multiplier 6 | SiteSetting.post_read_score_value 7 | end 8 | 9 | def self.query 10 | <<~SQL 11 | SELECT 12 | uv.user_id AS user_id, 13 | date_trunc('day', uv.visited_at) AS date, 14 | SUM(uv.posts_read) / 100 * #{score_multiplier} AS points 15 | FROM 16 | user_visits AS uv 17 | WHERE 18 | uv.visited_at >= :since AND 19 | uv.posts_read >= 5 20 | GROUP BY 21 | 1, 2 22 | SQL 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/discourse_gamification/scorables/time_read.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseGamification 4 | class TimeRead < Scorable 5 | def self.score_multiplier 6 | SiteSetting.time_read_score_value 7 | end 8 | 9 | def self.query 10 | <<~SQL 11 | SELECT 12 | uv.user_id AS user_id, 13 | date_trunc('day', uv.visited_at) AS date, 14 | SUM(uv.time_read) / 3600 * #{score_multiplier} AS points 15 | FROM 16 | user_visits AS uv 17 | WHERE 18 | uv.visited_at >= :since AND 19 | uv.time_read >= 60 20 | GROUP BY 21 | 1, 2 22 | SQL 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/connectors/user-card-metadata/gamification-score.gjs: -------------------------------------------------------------------------------- 1 | import Component from "@ember/component"; 2 | import { classNames, tagName } from "@ember-decorators/component"; 3 | import { i18n } from "discourse-i18n"; 4 | import GamificationScore from "../../components/gamification-score"; 5 | 6 | @tagName("div") 7 | @classNames("user-card-metadata-outlet", "gamification-score") 8 | export default class GamificationScoreConnector extends Component { 9 | 15 | } 16 | -------------------------------------------------------------------------------- /lib/discourse_gamification/scorables/user_invited.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseGamification 4 | class UserInvited < Scorable 5 | def self.score_multiplier 6 | SiteSetting.user_invited_score_value 7 | end 8 | 9 | def self.query 10 | <<~SQL 11 | SELECT 12 | inv.invited_by_id AS user_id, 13 | date_trunc('day', inv.created_at) AS date, 14 | SUM(inv.redemption_count * #{score_multiplier}) AS points 15 | FROM 16 | invites AS inv 17 | WHERE 18 | inv.created_at >= :since AND 19 | inv.redemption_count > 0 20 | GROUP BY 21 | 1, 2 22 | SQL 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /assets/javascripts/initializers/admin-plugin-configuration-nav.js: -------------------------------------------------------------------------------- 1 | import { withPluginApi } from "discourse/lib/plugin-api"; 2 | 3 | export default { 4 | name: "discourse-gamification-admin-plugin-configuration-nav", 5 | 6 | initialize(container) { 7 | const currentUser = container.lookup("service:current-user"); 8 | if (!currentUser || !currentUser.admin) { 9 | return; 10 | } 11 | 12 | withPluginApi("1.1.0", (api) => { 13 | api.addAdminPluginConfigurationNav("discourse-gamification", [ 14 | { 15 | label: "gamification.leaderboard.title", 16 | route: "adminPlugins.show.discourse-gamification-leaderboards", 17 | }, 18 | ]); 19 | }); 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /jobs/regular/generate_leaderboard_positions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jobs 4 | class GenerateLeaderboardPositions < ::Jobs::Base 5 | def execute(args) 6 | leaderboard_id = args[:leaderboard_id] 7 | raise Discourse::InvalidParameters.new(:leaderboard_id) if leaderboard_id.blank? 8 | 9 | DistributedMutex.synchronize( 10 | "gamification_generate_leaderboard_positions_#{leaderboard_id}", 11 | validity: 5.minutes, 12 | ) do 13 | leaderboard = DiscourseGamification::GamificationLeaderboard.find_by(id: leaderboard_id) 14 | return unless leaderboard 15 | 16 | DiscourseGamification::LeaderboardCachedView.new(leaderboard).create 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/jobs/generate_leaderboard_positions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe Jobs::GenerateLeaderboardPositions do 6 | fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } 7 | fab!(:score) { Fabricate(:gamification_score, user_id: leaderboard.created_by_id) } 8 | let(:leaderboard_positions) { DiscourseGamification::LeaderboardCachedView.new(leaderboard) } 9 | 10 | it "generates leaderboard positions" do 11 | expect { leaderboard_positions.scores }.to raise_error( 12 | DiscourseGamification::LeaderboardCachedView::NotReadyError, 13 | ) 14 | 15 | described_class.new.execute(leaderboard_id: leaderboard.id) 16 | 17 | expect(leaderboard_positions.scores.length).to eq(1) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # **Discourse Gamification** Plugin 2 | 3 | > [!IMPORTANT] 4 | > This plugin has now been bundled into Discourse core. See: https://meta.discourse.org/t/bundling-more-popular-plugins-with-discourse-core/373574 5 | 6 | # User Card 7 | Screen Shot 2022-03-18 at 9 33 24 AM 8 | 9 | # User Metadata 10 | Screen Shot 2022-03-18 at 10 48 25 AM 11 | 12 | # Directory 13 | Screen Shot 2022-03-18 at 10 48 54 AM 14 | -------------------------------------------------------------------------------- /jobs/regular/recalculate_scores.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jobs 4 | class RecalculateScores < ::Jobs::Base 5 | def execute(args) 6 | user_id = args[:user_id] 7 | raise Discourse::InvalidParameters.new(:user_id) if user_id.blank? 8 | 9 | DiscourseGamification::GamificationScore.calculate_scores( 10 | since_date: args[:since] || 10.days.ago, 11 | ) 12 | 13 | ::MessageBus.publish "/recalculate_scores", 14 | { 15 | success: true, 16 | remaining: 17 | DiscourseGamification::RecalculateScoresRateLimiter.remaining, 18 | user_id: [user_id], 19 | } 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /assets/stylesheets/common/leaderboard-admin.scss: -------------------------------------------------------------------------------- 1 | .leaderboard-admin { 2 | &__title { 3 | display: inline-block; 4 | } 5 | 6 | &__cta-new { 7 | display: flex; 8 | margin-top: 1rem; 9 | } 10 | 11 | &__btn-recalculate { 12 | float: right; 13 | margin-right: 1rem; 14 | } 15 | 16 | &__btn-new { 17 | float: right; 18 | } 19 | 20 | &__btn-back { 21 | margin-bottom: 1rem; 22 | padding-left: 0; 23 | } 24 | 25 | &__listitem-action { 26 | text-align: right; 27 | display: flex; 28 | flex-direction: row; 29 | gap: 0.5em; 30 | justify-content: flex-end; 31 | } 32 | } 33 | 34 | .leaderboard-edit { 35 | &__cancel { 36 | margin-left: 1rem; 37 | } 38 | } 39 | 40 | .new-leaderboard-container { 41 | .form-kit__row { 42 | padding-top: 0; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /spec/models/user_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe User, type: :model do 6 | fab!(:user) 7 | fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } 8 | 9 | before do 10 | Fabricate(:gamification_score, user_id: user.id, score: 10, date: 8.days.ago) 11 | Fabricate(:gamification_score, user_id: user.id, score: 25, date: 5.days.ago) 12 | leaderboard.update(from_date: 5.days.ago.to_date) 13 | 14 | DiscourseGamification::LeaderboardCachedView.create_all 15 | end 16 | 17 | describe "#gamification_score" do 18 | it "returns default leaderboard 'all_time' total score" do 19 | expect(DiscourseGamification::GamificationScore.where(user_id: user.id).sum(:score)).to eq(35) 20 | expect(user.gamification_score).to eq(25) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /db/post_migrate/20250210133038_drop_versioned_leaderboard_materialized_views.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DropVersionedLeaderboardMaterializedViews < ActiveRecord::Migration[7.2] 4 | def up 5 | versioned_mviews_query = <<~SQL 6 | SELECT cls.relname 7 | FROM pg_class cls 8 | INNER JOIN pg_namespace ns ON ns.oid = cls.relnamespace 9 | WHERE cls.relname ~ 'gamification_leaderboard_cache_[0-9]+_[a-zA-Z_]+_[1-9]$' 10 | AND cls.relkind = 'm' 11 | AND ns.nspname = 'public' 12 | SQL 13 | 14 | mviews = DB.query_single(versioned_mviews_query) 15 | 16 | return if mviews.empty? 17 | 18 | execute <<~SQL 19 | DROP MATERIALIZED VIEW IF EXISTS #{mviews.join(", ")} CASCADE 20 | SQL 21 | end 22 | 23 | def down 24 | raise ActiveRecord::IrreversibleMigration 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/gamification-score.gjs: -------------------------------------------------------------------------------- 1 | import Component from "@ember/component"; 2 | import { LinkTo } from "@ember/routing"; 3 | import { classNames, tagName } from "@ember-decorators/component"; 4 | import fullnumber from "../helpers/fullnumber"; 5 | 6 | @tagName("span") 7 | @classNames("gamification-score") 8 | export default class GamificationScore extends Component { 9 | 22 | } 23 | -------------------------------------------------------------------------------- /app/models/discourse_gamification/gamification_score_event.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseGamification 4 | class GamificationScoreEvent < ::ActiveRecord::Base 5 | self.table_name = "gamification_score_events" 6 | 7 | belongs_to :user 8 | end 9 | end 10 | 11 | # == Schema Information 12 | # 13 | # Table name: gamification_score_events 14 | # 15 | # id :bigint not null, primary key 16 | # user_id :integer not null 17 | # date :date not null 18 | # points :integer not null 19 | # description :text 20 | # created_at :datetime not null 21 | # updated_at :datetime not null 22 | # 23 | # Indexes 24 | # 25 | # index_gamification_score_events_on_date (date) 26 | # index_gamification_score_events_on_user_id_and_date (user_id,date) 27 | # 28 | -------------------------------------------------------------------------------- /lib/discourse_gamification/user_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseGamification 4 | module UserExtension 5 | DEFAULT_SCORE = 0 6 | 7 | def gamification_score 8 | return DEFAULT_SCORE if !default_leaderboard 9 | 10 | DiscourseGamification::GamificationLeaderboard.find_position_by( 11 | leaderboard_id: default_leaderboard.id, 12 | period: "all_time", 13 | for_user_id: self.id, 14 | )&.total_score || DEFAULT_SCORE 15 | rescue DiscourseGamification::LeaderboardCachedView::NotReadyError 16 | Jobs.enqueue(Jobs::GenerateLeaderboardPositions, leaderboard_id: default_leaderboard.id) 17 | 18 | DEFAULT_SCORE 19 | end 20 | 21 | def default_leaderboard 22 | @default_leaderboard ||= DiscourseGamification::GamificationLeaderboard.select(:id).first 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/jobs/refresh_leaderboard_positions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe Jobs::RefreshLeaderboardPositions do 6 | fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } 7 | let(:leaderboard_positions) { DiscourseGamification::LeaderboardCachedView.new(leaderboard) } 8 | 9 | before { leaderboard_positions.create } 10 | 11 | it "refreshes leaderboard positions" do 12 | Fabricate(:gamification_score, user_id: leaderboard.created_by_id, score: 10) 13 | 14 | expect(leaderboard_positions.scores).to be_empty 15 | 16 | described_class.new.execute(leaderboard_id: leaderboard.id) 17 | 18 | expect(leaderboard_positions.scores.length).to eq(1) 19 | expect(leaderboard_positions.scores.first.attributes).to include( 20 | "id" => leaderboard.created_by_id, 21 | "total_score" => 10, 22 | ) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /config/locales/client.id.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | id: 8 | js: 9 | gamification: 10 | you: "Anda" 11 | leaderboard: 12 | link_to_settings: "Pengaturan" 13 | refresh: "Segarkan" 14 | name: "Nama" 15 | period: 16 | all_time: "Sepanjang Waktu" 17 | create: "Buat" 18 | cancel: "Batal" 19 | close: "Tutup" 20 | delete: "Hapus" 21 | edit: "Ubah" 22 | save: "Simpan" 23 | apply: "menerapkan" 24 | update_range: 25 | last_10_days: "10 hari terakhir" 26 | last_30_days: "30 hari terakhir" 27 | last_90_days: "90 hari terakhir" 28 | all_time: "Sepanjang Waktu" 29 | custom_range_from: "Dari" 30 | admin: 31 | name: "Nama" 32 | -------------------------------------------------------------------------------- /config/locales/client.sq.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sq: 8 | js: 9 | gamification: 10 | you: "Ju" 11 | leaderboard: 12 | link_to_settings: "Rregullimet" 13 | refresh: "Rifresko" 14 | name: "Emri" 15 | period: 16 | all_time: "Gjithë Kohës" 17 | yearly: "Vjetore" 18 | quarterly: "Tremujorsh" 19 | monthly: "Mujore" 20 | weekly: "Javore" 21 | daily: "Ditore" 22 | cancel: "Anulo" 23 | close: "Mbyll" 24 | delete: "Fshij" 25 | edit: "Redakto" 26 | back: "Kthehu mbrapa" 27 | save: "Ruaj" 28 | apply: "Apliko" 29 | update_range: 30 | all_time: "Gjithë Kohës" 31 | custom_range_from: "Nga" 32 | admin: 33 | name: "Emri" 34 | -------------------------------------------------------------------------------- /lib/discourse_gamification/scorables/topic_created.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseGamification 4 | class TopicCreated < Scorable 5 | def self.score_multiplier 6 | SiteSetting.topic_created_score_value 7 | end 8 | 9 | def self.category_filter 10 | return "" if scorable_category_list.empty? 11 | 12 | <<~SQL 13 | AND t.category_id IN (#{scorable_category_list}) 14 | SQL 15 | end 16 | 17 | def self.query 18 | <<~SQL 19 | SELECT 20 | t.user_id AS user_id, 21 | date_trunc('day', t.created_at) AS date, 22 | COUNT(*) * #{score_multiplier} AS points 23 | FROM 24 | topics AS t 25 | WHERE 26 | t.deleted_at IS NULL AND 27 | t.archetype <> 'private_message' AND 28 | t.created_at >= :since 29 | #{category_filter} 30 | GROUP BY 31 | 1, 2 32 | SQL 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /config/locales/client.bs_BA.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | bs_BA: 8 | js: 9 | gamification: 10 | you: "Vi" 11 | leaderboard: 12 | link_to_settings: "Postavke" 13 | refresh: "Refresh" 14 | name: "Ime" 15 | period: 16 | all_time: "Oduvijek" 17 | yearly: "Godišnje" 18 | quarterly: "Kvartalno" 19 | monthly: "Mesečno" 20 | weekly: "Sedmično" 21 | daily: "Dnevno" 22 | create: "napravi" 23 | cancel: "Odustani" 24 | close: "Zatvori" 25 | delete: "Delete" 26 | edit: "Edit" 27 | back: "Prethodno" 28 | save: "Save" 29 | apply: "Snimi" 30 | update_range: 31 | all_time: "Oduvijek" 32 | custom_range_from: "Od" 33 | admin: 34 | name: "Ime" 35 | -------------------------------------------------------------------------------- /config/locales/client.be.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | be: 8 | js: 9 | gamification: 10 | you: "Вы" 11 | leaderboard: 12 | link_to_settings: "Налады" 13 | refresh: "абнавіць" 14 | name: "імя" 15 | period: 16 | all_time: "За ўвесь час" 17 | yearly: "штогод" 18 | quarterly: "штоквартальна" 19 | monthly: "штомесяц" 20 | weekly: "штотыдзень" 21 | daily: "штодня" 22 | create: "стварыць" 23 | cancel: "адмяніць" 24 | close: "зачыніць" 25 | delete: "выдаляць" 26 | edit: "рэдагаваць" 27 | back: "Назад" 28 | save: "захаваць" 29 | apply: "прымяніць" 30 | update_range: 31 | all_time: "За ўвесь час" 32 | custom_range_from: "ад" 33 | admin: 34 | name: "імя" 35 | -------------------------------------------------------------------------------- /config/locales/client.ug.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ug: 8 | js: 9 | gamification: 10 | leaderboard: 11 | link_to_settings: "تەڭشەكلەر" 12 | refresh: "يېڭىلا" 13 | name: "ئىسمى" 14 | name_placeholder: "ئىسمى…" 15 | period: 16 | all_time: "ھەممە ۋاقىت" 17 | yearly: "يىللىق" 18 | quarterly: "پەسىللىك" 19 | monthly: "ئايلىق" 20 | weekly: "ھەپتىلىك" 21 | daily: "كۈندىلىك" 22 | create: "قۇر" 23 | cancel: "ۋاز كەچ" 24 | close: "تاقا" 25 | delete: "ئۆچۈر" 26 | edit: "تەھرىر" 27 | back: "كەينى" 28 | save: "ساقلا" 29 | apply: "قوللان" 30 | update_range: 31 | all_time: "ھەممە ۋاقىت" 32 | custom_range_from: "كىمدىن" 33 | admin: 34 | name: "ئىسمى" 35 | -------------------------------------------------------------------------------- /assets/stylesheets/mobile/leaderboard.scss: -------------------------------------------------------------------------------- 1 | .leaderboard { 2 | &__period-chooser.select-kit.dropdown-select-box { 3 | display: flex; 4 | margin: 1rem 0; 5 | 6 | .period-chooser-header { 7 | text-align: center; 8 | } 9 | } 10 | 11 | .page__header button.-ghost, 12 | &__settings { 13 | font-size: var(--font-up-2); 14 | padding: 0.5rem 0 0.5rem 0.5rem; 15 | } 16 | 17 | .winner { 18 | width: 30%; 19 | 20 | &__avatar { 21 | img { 22 | border-width: 3px; 23 | } 24 | } 25 | 26 | &__rank { 27 | width: 40px; 28 | height: 40px; 29 | font-size: 24px; 30 | } 31 | 32 | &__score { 33 | font-size: var(--font-up-3); 34 | } 35 | 36 | &.-position1 { 37 | width: 40%; 38 | } 39 | } 40 | 41 | .ranking-col-names { 42 | padding: 1rem 1rem 0.25rem 1rem; 43 | } 44 | 45 | .user { 46 | padding: 0.5rem 1rem; 47 | 48 | &__score { 49 | font-size: var(--font-up-3); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /config/locales/client.te.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | te: 8 | js: 9 | gamification: 10 | you: "మీరు" 11 | leaderboard: 12 | link_to_settings: "అమరికలు" 13 | refresh: "తాజాపరుచు" 14 | name: "పేరు" 15 | period: 16 | all_time: "ఆల్ టైమ్" 17 | yearly: "వార్షిక" 18 | quarterly: "త్రైమాసిక" 19 | monthly: "నెలవారీ" 20 | weekly: "వారానికోసారి" 21 | daily: "రోజువారీ" 22 | create: "సృష్టించండి" 23 | cancel: "రద్దుచేయి" 24 | close: "మూసివేయి" 25 | delete: "తొలగించు" 26 | edit: "సవరణ" 27 | back: "వెనుకకు" 28 | save: "భద్రపరుచు" 29 | apply: "ఆపాదించు" 30 | update_range: 31 | all_time: "ఆల్ టైమ్" 32 | custom_range_from: "నుండి" 33 | admin: 34 | name: "పేరు" 35 | -------------------------------------------------------------------------------- /config/locales/client.gl.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | gl: 8 | js: 9 | gamification: 10 | you: "Vostede" 11 | leaderboard: 12 | link_to_settings: "Configuración" 13 | refresh: "Actualizar" 14 | name: "Nome" 15 | period: 16 | all_time: "Desde o principio" 17 | yearly: "Anual" 18 | quarterly: "Trimestral" 19 | monthly: "Mensual" 20 | weekly: "Semanal" 21 | daily: "Diario" 22 | create: "Crear" 23 | cancel: "Cancelar" 24 | close: "Pechar" 25 | delete: "Eliminar" 26 | edit: "Editar" 27 | back: "Volver" 28 | save: "Gardar" 29 | apply: "Aplicar" 30 | update_range: 31 | all_time: "Desde o principio" 32 | custom_range_from: "De" 33 | admin: 34 | name: "Nome" 35 | -------------------------------------------------------------------------------- /config/locales/client.ko.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ko: 8 | js: 9 | gamification: 10 | you: "사용자님" 11 | leaderboard: 12 | link_to_settings: "설정" 13 | refresh: "새로 고침" 14 | name: "그룹명" 15 | period: 16 | all_time: "전체 시간" 17 | yearly: "연" 18 | quarterly: "분기마다" 19 | monthly: "월" 20 | weekly: "주" 21 | daily: "일" 22 | create: "글" 23 | cancel: "취소" 24 | close: "닫기" 25 | delete: "삭제하기" 26 | edit: "편집" 27 | back: "뒤로" 28 | save: "저장하기" 29 | apply: "적용" 30 | update_range: 31 | last_10_days: "지난 10일" 32 | last_30_days: "지난 30일" 33 | last_90_days: "지난 90일" 34 | all_time: "전체 시간" 35 | custom_range_from: "보내는사람" 36 | admin: 37 | name: "이름" 38 | -------------------------------------------------------------------------------- /config/locales/client.et.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | et: 8 | js: 9 | gamification: 10 | you: "Sina" 11 | leaderboard: 12 | link_to_settings: "Sätted" 13 | refresh: "Värskenda" 14 | name: "Nimi" 15 | period: 16 | all_time: "Alates algusest" 17 | yearly: "Iga-aastaselt" 18 | quarterly: "Kvartaalselt" 19 | monthly: "Igakuiselt" 20 | weekly: "Iganädalaselt" 21 | daily: "Igapäevaselt" 22 | create: "Loo" 23 | cancel: "Tühista" 24 | close: "Sulge" 25 | delete: "Kustuta" 26 | edit: "Muuda" 27 | back: "Tagasi" 28 | save: "Salvesta" 29 | apply: "Rakenda" 30 | update_range: 31 | all_time: "Alates algusest" 32 | custom_range_from: "Kellelt" 33 | admin: 34 | name: "Nimi" 35 | -------------------------------------------------------------------------------- /config/locales/client.sw.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sw: 8 | js: 9 | gamification: 10 | you: "Wewe" 11 | leaderboard: 12 | link_to_settings: "Mipangilio" 13 | refresh: "Rudisha Tena" 14 | name: "Jina" 15 | period: 16 | all_time: "Wakati wote" 17 | yearly: "Kila Mwaka" 18 | quarterly: "Kila baada ya miezi mitatu" 19 | monthly: "Klla mwezi" 20 | weekly: "Kila wiki" 21 | daily: "Kila siku" 22 | create: "Tengeneza" 23 | cancel: "Ghairi" 24 | close: "Funga" 25 | delete: "Futa" 26 | edit: "Hariri" 27 | back: "Iliyopita" 28 | save: "Hifadhi" 29 | apply: "Tumia" 30 | update_range: 31 | all_time: "Wakati wote" 32 | custom_range_from: "Kutoka" 33 | admin: 34 | name: "Jina" 35 | -------------------------------------------------------------------------------- /admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-gamification-leaderboards-show.js: -------------------------------------------------------------------------------- 1 | import { service } from "@ember/service"; 2 | import { ajax } from "discourse/lib/ajax"; 3 | import DiscourseRoute from "discourse/routes/discourse"; 4 | import GamificationLeaderboard from "discourse/plugins/discourse-gamification/discourse/models/gamification-leaderboard"; 5 | 6 | export default class DiscourseGamificationLeaderboardShow extends DiscourseRoute { 7 | @service adminPluginNavManager; 8 | 9 | model(params) { 10 | const leaderboardsData = this.modelFor( 11 | "adminPlugins.show.discourse-gamification-leaderboards" 12 | ); 13 | const id = parseInt(params.id, 10); 14 | 15 | const leaderboard = leaderboardsData.leaderboards.findBy("id", id); 16 | if (leaderboard) { 17 | return leaderboard; 18 | } 19 | 20 | return ajax( 21 | `/admin/plugins/discourse-gamification/leaderboards/${id}` 22 | ).then((response) => GamificationLeaderboard.create(response.leaderboard)); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /config/locales/client.zh_TW.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | zh_TW: 8 | js: 9 | gamification: 10 | you: "你" 11 | leaderboard: 12 | link_to_settings: "設定" 13 | refresh: "重新整理" 14 | name: "名字" 15 | name_placeholder: "名字..." 16 | period: 17 | all_time: "所以時間" 18 | yearly: "年" 19 | quarterly: "季度" 20 | monthly: "月" 21 | weekly: "周" 22 | daily: "日" 23 | create: "創建" 24 | cancel: "取消" 25 | close: "關閉" 26 | delete: "刪除" 27 | edit: "編輯" 28 | back: "上一步" 29 | save: "保存" 30 | apply: "套用" 31 | update_range: 32 | last_10_days: "過去 10 天" 33 | last_30_days: "過去 30 天" 34 | last_90_days: "過去 90 天" 35 | all_time: "所有時間" 36 | custom_range_from: "來自" 37 | admin: 38 | name: "名字" 39 | -------------------------------------------------------------------------------- /config/locales/client.hy.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hy: 8 | js: 9 | gamification: 10 | you: "Դուք " 11 | leaderboard: 12 | link_to_settings: "Կարգավորումներ" 13 | refresh: "Թարմացնել" 14 | name: "Անուն" 15 | period: 16 | all_time: "Ամբողջ Ժամանակվա" 17 | yearly: "Տարվա Ընթացքում" 18 | quarterly: "Եռամսյակի Ընթացքում" 19 | monthly: "Ամսվա Ընթացքում" 20 | weekly: "Շաբաթվա Ընթացքում" 21 | daily: "Օրվա Ընթացքում" 22 | create: "Ստեղծել" 23 | cancel: "Չեղարկել" 24 | close: "Փակել" 25 | delete: "Ջնջել" 26 | edit: "Խմբագրել" 27 | back: "Ետ" 28 | save: "Պահպանել" 29 | apply: "Կիրառել" 30 | update_range: 31 | all_time: "Ամբողջ Ժամանակվա" 32 | custom_range_from: "Ում կողմից" 33 | admin: 34 | name: "Անուն" 35 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/period-input.js: -------------------------------------------------------------------------------- 1 | import { computed } from "@ember/object"; 2 | import { classNames } from "@ember-decorators/component"; 3 | import { i18n } from "discourse-i18n"; 4 | import ComboBoxComponent from "select-kit/components/combo-box"; 5 | import { 6 | pluginApiIdentifiers, 7 | selectKitOptions, 8 | } from "select-kit/components/select-kit"; 9 | import { LEADERBOARD_PERIODS } from "discourse/plugins/discourse-gamification/discourse/components/gamification-leaderboard"; 10 | 11 | @selectKitOptions({ 12 | filterable: true, 13 | allowAny: false, 14 | }) 15 | @pluginApiIdentifiers("period-input") 16 | @classNames("period-input", "period-input") 17 | export default class PeriodInput extends ComboBoxComponent { 18 | @computed 19 | get content() { 20 | let periods = []; 21 | 22 | periods = periods.concat( 23 | LEADERBOARD_PERIODS.map((period, index) => ({ 24 | name: i18n(`gamification.leaderboard.period.${period}`), 25 | id: index, 26 | })) 27 | ); 28 | 29 | return periods; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/serializers/leaderboard_view_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class LeaderboardViewSerializer < ApplicationSerializer 4 | attributes :personal 5 | 6 | has_one :leaderboard, serializer: LeaderboardSerializer, embed: :objects 7 | has_many :users, serializer: UserScoreSerializer, embed: :objects 8 | 9 | def leaderboard 10 | object[:leaderboard] 11 | end 12 | 13 | def users 14 | DiscourseGamification::GamificationLeaderboard.scores_for( 15 | object[:leaderboard].id, 16 | page: object[:page], 17 | period: object[:period], 18 | user_limit: object[:user_limit], 19 | ) 20 | end 21 | 22 | def personal 23 | return {} if object[:for_user_id].blank? 24 | 25 | user_score = 26 | DiscourseGamification::GamificationLeaderboard.scores_for( 27 | object[:leaderboard].id, 28 | for_user_id: object[:for_user_id], 29 | period: object[:period], 30 | ).take 31 | 32 | { user: UserScoreSerializer.new(user_score, root: false), position: user_score.try(:position) } 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/system/page_objects/modals/recalculate_scores_form.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PageObjects 4 | module Modals 5 | class RecalculateScoresForm < PageObjects::Modals::Base 6 | def update_range_dropdown 7 | PageObjects::Components::SelectKit.new("#update-range") 8 | end 9 | 10 | def select_update_range(value: nil) 11 | update_range_dropdown.expand 12 | update_range_dropdown.select_row_by_value(value) 13 | end 14 | 15 | def fill_since_date(since) 16 | find("#custom-from-date").fill_in(with: since) 17 | end 18 | 19 | def date_range 20 | find(".recalculate-modal__date-range") 21 | end 22 | 23 | def custom_since_date 24 | find("#custom-from-date input") 25 | end 26 | 27 | def status 28 | find(".recalculate-modal__status") 29 | end 30 | 31 | def remaining 32 | find(".recalculate-modal__footer-text") 33 | end 34 | 35 | def apply 36 | find("#apply-section") 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-gamification-leaderboards.js: -------------------------------------------------------------------------------- 1 | import EmberObject from "@ember/object"; 2 | import { service } from "@ember/service"; 3 | import DiscourseRoute from "discourse/routes/discourse"; 4 | import GamificationLeaderboard from "discourse/plugins/discourse-gamification/discourse/models/gamification-leaderboard"; 5 | 6 | export default class DiscourseGamificationLeaderboards extends DiscourseRoute { 7 | @service adminPluginNavManager; 8 | 9 | model() { 10 | if (!this.currentUser?.admin) { 11 | return { model: null }; 12 | } 13 | const gamificationPlugin = this.adminPluginNavManager.currentPlugin; 14 | 15 | return EmberObject.create({ 16 | leaderboards: gamificationPlugin.extras.gamification_leaderboards.map( 17 | (leaderboard) => GamificationLeaderboard.create(leaderboard) 18 | ), 19 | groups: gamificationPlugin.extras.gamification_groups, 20 | recalculate_scores_remaining: 21 | gamificationPlugin.extras.gamification_recalculate_scores_remaining, 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /config/locales/client.sr.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sr: 8 | js: 9 | gamification: 10 | you: "Ti" 11 | leaderboard: 12 | link_to_settings: "Podešavanja" 13 | refresh: "Osveži" 14 | name: "Ime foruma" 15 | period: 16 | all_time: "Oduvek" 17 | yearly: "Top godišnje" 18 | quarterly: "Top kvartalne" 19 | monthly: "Top mesečne" 20 | weekly: "Top nedeljne" 21 | daily: "Top dnevne" 22 | cancel: "Odustani" 23 | close: "Zatvori" 24 | delete: "Obriši" 25 | edit: "Izmeni" 26 | back: "Nazad" 27 | save: "Sačuvaj" 28 | apply: "Primeni" 29 | update_range: 30 | last_10_days: "Последњих 10 дана" 31 | last_30_days: "Последњих 30 дана" 32 | last_90_days: "Последњих 90 дана" 33 | all_time: "Oduvek" 34 | admin: 35 | name: "Ime foruma" 36 | -------------------------------------------------------------------------------- /config/locales/client.th.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | th: 8 | js: 9 | gamification: 10 | you: "คุณ" 11 | leaderboard: 12 | link_to_settings: "การตั้งค่า" 13 | refresh: "รีเฟรช" 14 | name: "ชื่อ" 15 | period: 16 | all_time: "ตลอดเวลา" 17 | yearly: "รายปี" 18 | quarterly: "รายไตรมาส" 19 | monthly: "รายเดือน" 20 | weekly: "รายสัปดาห์" 21 | daily: "รายวัน" 22 | create: "สร้าง" 23 | cancel: "ยกเลิก" 24 | close: "ปิด" 25 | delete: "ลบ" 26 | edit: "แก้ไข" 27 | back: "กลับ" 28 | save: "บันทึก" 29 | apply: "นำไปใช้" 30 | update_range: 31 | last_10_days: "10 วันที่ผ่านมา" 32 | last_30_days: "30 วันที่ผ่านมา" 33 | last_90_days: "90 วันที่ผ่านมา" 34 | all_time: "ตลอดเวลา" 35 | custom_range_from: "จาก" 36 | admin: 37 | name: "ชื่อ" 38 | -------------------------------------------------------------------------------- /config/locales/client.ur.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ur: 8 | js: 9 | gamification: 10 | you: "آپ " 11 | leaderboard: 12 | link_to_settings: "ترتیبات" 13 | refresh: "رِیفریش" 14 | name: "نام" 15 | period: 16 | all_time: "تمام اوقات" 17 | yearly: "سالانہ" 18 | quarterly: "سہ ماہی" 19 | monthly: "ماہانہ" 20 | weekly: "ہفتہ وار" 21 | daily: "روزانہ" 22 | create: "بنائیں" 23 | cancel: "منسوخ" 24 | close: "بند کریں" 25 | delete: "مٹائیں" 26 | edit: "ترمیم کریں" 27 | back: "واپس" 28 | save: "محفوظ کریں" 29 | apply: "لاگو کریں" 30 | update_range: 31 | last_10_days: "پچھلے 10 دن" 32 | last_30_days: "پچھلے 30 دن" 33 | last_90_days: "پچھلے 90 دن" 34 | all_time: "تمام اوقات" 35 | custom_range_from: "سے" 36 | admin: 37 | name: "نام" 38 | -------------------------------------------------------------------------------- /config/locales/client.da.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | da: 8 | js: 9 | gamification: 10 | you: "Dig" 11 | leaderboard: 12 | link_to_settings: "Indstillinger" 13 | refresh: "Genindlæs" 14 | name: "Navn" 15 | period: 16 | all_time: "Alt" 17 | yearly: "Årligt" 18 | quarterly: "Kvartalvis" 19 | monthly: "Månedligt" 20 | weekly: "Ugentligt" 21 | daily: "Dagligt" 22 | create: "Opret" 23 | cancel: "Annuller" 24 | close: "Luk" 25 | delete: "Slet" 26 | edit: "Rediger" 27 | back: "Tilbage" 28 | save: "Gem" 29 | apply: "Anvend" 30 | update_range: 31 | last_10_days: "Seneste 10 dage" 32 | last_30_days: "Seneste 30 dage" 33 | last_90_days: "Seneste 90 dage" 34 | all_time: "Alt" 35 | custom_range_from: "Fra" 36 | admin: 37 | name: "Navn" 38 | -------------------------------------------------------------------------------- /config/locales/client.vi.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | vi: 8 | js: 9 | gamification: 10 | you: "Bạn" 11 | leaderboard: 12 | link_to_settings: "Cài đặt" 13 | refresh: "Làm mới" 14 | name: "Tên" 15 | period: 16 | all_time: "Từ trước tới nay" 17 | yearly: "Hàng năm" 18 | quarterly: "Hàng quý" 19 | monthly: "Hàng tháng" 20 | weekly: "Hàng tuần" 21 | daily: "hằng ngày" 22 | create: "Tạo" 23 | cancel: "Huỷ" 24 | close: "Đóng" 25 | delete: "Xóa" 26 | edit: "Sửa" 27 | back: "Quay lại" 28 | save: "Lưu lại" 29 | apply: "Áp dụng" 30 | update_range: 31 | last_10_days: "10 ngày qua" 32 | last_30_days: "30 ngày qua" 33 | last_90_days: "90 ngày qua" 34 | all_time: "Từ trước tới nay" 35 | custom_range_from: "Từ" 36 | admin: 37 | name: "Tên" 38 | -------------------------------------------------------------------------------- /lib/discourse_gamification/scorables/chat_reaction_given.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module ::DiscourseGamification 3 | class ChatReactionGiven < Scorable 4 | def self.enabled? 5 | SiteSetting.chat_enabled && score_multiplier > 0 6 | end 7 | 8 | def self.score_multiplier 9 | SiteSetting.chat_reaction_given_score_value 10 | end 11 | 12 | def self.query 13 | <<~SQL 14 | SELECT 15 | reactions.user_id AS user_id, 16 | date_trunc('day', reactions.created_at) AS date, 17 | COUNT(*) * #{score_multiplier} AS points 18 | FROM 19 | chat_message_reactions AS reactions 20 | INNER JOIN chat_messages AS cm 21 | ON cm.id = reactions.chat_message_id 22 | INNER JOIN chat_channels AS cc 23 | ON cc.id = cm.chat_channel_id 24 | WHERE 25 | cc.deleted_at IS NULL AND 26 | cm.deleted_at IS NULL AND 27 | cm.user_id <> reactions.user_id AND 28 | reactions.created_at >= :since 29 | GROUP BY 30 | 1, 2 31 | SQL 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/discourse_gamification/scorables/chat_reaction_received.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module ::DiscourseGamification 3 | class ChatReactionReceived < Scorable 4 | def self.enabled? 5 | SiteSetting.chat_enabled && score_multiplier > 0 6 | end 7 | 8 | def self.score_multiplier 9 | SiteSetting.chat_reaction_received_score_value 10 | end 11 | 12 | def self.query 13 | <<~SQL 14 | SELECT 15 | cm.user_id AS user_id, 16 | date_trunc('day', reactions.created_at) AS date, 17 | COUNT(*) * #{score_multiplier} AS points 18 | FROM 19 | chat_message_reactions AS reactions 20 | INNER JOIN chat_messages AS cm 21 | ON cm.id = reactions.chat_message_id 22 | INNER JOIN chat_channels AS cc 23 | ON cc.id = cm.chat_channel_id 24 | WHERE 25 | cc.deleted_at IS NULL AND 26 | cm.deleted_at IS NULL AND 27 | cm.user_id <> reactions.user_id AND 28 | reactions.created_at >= :since 29 | GROUP BY 30 | 1, 2 31 | SQL 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /config/locales/client.sl.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sl: 8 | js: 9 | gamification: 10 | you: "Vi" 11 | leaderboard: 12 | link_to_settings: "Nastavitve" 13 | refresh: "Osveži" 14 | name: "Ime" 15 | period: 16 | all_time: "Ves čas" 17 | yearly: "V letu" 18 | quarterly: "V četrtletju" 19 | monthly: "Mesečno" 20 | weekly: "Tedensko" 21 | daily: "Dnevno" 22 | create: "Ustvari" 23 | cancel: "Prekliči" 24 | close: "Zapri" 25 | delete: "Izbriši" 26 | edit: "Uredi" 27 | back: "Nazaj" 28 | save: "Shrani" 29 | apply: "Uporabi" 30 | update_range: 31 | last_10_days: "Zadnjih 10 dni" 32 | last_30_days: "Zadnjih 30 dni" 33 | last_90_days: "Zadnjih 90 dni" 34 | all_time: "Ves čas" 35 | custom_range_from: "Od" 36 | admin: 37 | name: "Polno ime" 38 | -------------------------------------------------------------------------------- /config/locales/client.lv.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | lv: 8 | js: 9 | gamification: 10 | you: "Tu" 11 | leaderboard: 12 | link_to_settings: "Iestatījumi" 13 | refresh: "Pārlādēt" 14 | name: "Vārds" 15 | period: 16 | all_time: "Vienmēr" 17 | yearly: "Gada" 18 | quarterly: "Ceturkšņa" 19 | monthly: "Mēneša" 20 | weekly: "Nedēļas" 21 | daily: "Dienas" 22 | create: "Izveidot" 23 | cancel: "Atcelt" 24 | close: "Aizvērt" 25 | delete: "Dzēst" 26 | edit: "Rediģēt" 27 | back: "Atpakaļ" 28 | save: "Saglabāt" 29 | apply: "Pielietot" 30 | update_range: 31 | last_10_days: "Pēdējās 10 dienas" 32 | last_30_days: "Pēdējās 30 dienas" 33 | last_90_days: "Pēdējās 90 dienas" 34 | all_time: "Vienmēr" 35 | custom_range_from: "No" 36 | admin: 37 | name: "Vārds" 38 | -------------------------------------------------------------------------------- /config/locales/client.nb_NO.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | nb_NO: 8 | js: 9 | gamification: 10 | you: "Du" 11 | leaderboard: 12 | link_to_settings: "Instillinger" 13 | refresh: "Last inn siden på nytt" 14 | name: "Navn" 15 | period: 16 | all_time: "Totalt" 17 | yearly: "Årlig" 18 | quarterly: "Kvartalsvis" 19 | monthly: "Månedlig" 20 | weekly: "Ukentlig" 21 | daily: "Daglig" 22 | create: "Opprett" 23 | cancel: "Avbryt" 24 | close: "Lukk" 25 | delete: "Slett" 26 | edit: "Endre" 27 | back: "Forrige" 28 | save: "Lagre" 29 | apply: "Bruk" 30 | update_range: 31 | last_10_days: "Siste 10 dager" 32 | last_30_days: "Siste 30 dager" 33 | last_90_days: "Siste 90 dager" 34 | all_time: "Totalt" 35 | custom_range_from: "Fra" 36 | admin: 37 | name: "Navn" 38 | -------------------------------------------------------------------------------- /config/locales/client.bg.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | bg: 8 | js: 9 | gamification: 10 | you: "Вие" 11 | leaderboard: 12 | link_to_settings: "Настройки" 13 | refresh: "Обнови" 14 | name: "Име" 15 | period: 16 | all_time: "От началото" 17 | yearly: "Годишно" 18 | quarterly: "Тримесечно" 19 | monthly: "Месечно" 20 | weekly: "Седмично" 21 | daily: "Дневно" 22 | create: "Създай" 23 | cancel: "Прекрати" 24 | close: "Затвори" 25 | delete: "Изтрий" 26 | edit: "Редактирай" 27 | back: "Назад" 28 | save: "Запази " 29 | apply: "Приложи" 30 | update_range: 31 | last_10_days: "Последните 10 дни" 32 | last_30_days: "Последните 30 дни" 33 | last_90_days: "Последните 90 дни" 34 | all_time: "От началото" 35 | custom_range_from: "От" 36 | admin: 37 | name: "Име" 38 | -------------------------------------------------------------------------------- /config/locales/client.pt.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | pt: 8 | js: 9 | gamification: 10 | you: "Você" 11 | leaderboard: 12 | link_to_settings: "Configurações" 13 | refresh: "Atualizar" 14 | name: "Nome" 15 | period: 16 | all_time: "Desde Sempre" 17 | yearly: "Anual" 18 | quarterly: "Trimestral" 19 | monthly: "Mensal" 20 | weekly: "Semanal" 21 | daily: "Diário" 22 | create: "Criar" 23 | cancel: "Cancelar" 24 | close: "Fechar" 25 | delete: "Eliminar" 26 | edit: "Editar" 27 | back: "Retroceder" 28 | save: "Guardar" 29 | apply: "Aplicar" 30 | update_range: 31 | last_10_days: "Últimos 10 Dias" 32 | last_30_days: "Últimos 30 Dias" 33 | last_90_days: "Últimos 90 Dias" 34 | all_time: "Desde Sempre" 35 | custom_range_from: "De" 36 | admin: 37 | name: "Nome" 38 | -------------------------------------------------------------------------------- /config/locales/client.ca.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ca: 8 | js: 9 | gamification: 10 | you: "Vós" 11 | leaderboard: 12 | link_to_settings: "Configuració" 13 | refresh: "Actualitza" 14 | name: "Nom" 15 | period: 16 | all_time: "Sempre" 17 | yearly: "Anualment" 18 | quarterly: "Trimestralment" 19 | monthly: "Mensualment" 20 | weekly: "Setmanalment" 21 | daily: "Diàriament" 22 | create: "Crea" 23 | cancel: "Cancel·la" 24 | close: "Tanca" 25 | delete: "Suprimeix" 26 | edit: "Edita" 27 | back: "Enrere" 28 | save: "Desa" 29 | apply: "Aplica" 30 | update_range: 31 | last_10_days: "Els darrers 10 dies" 32 | last_30_days: "Els darrers 30 dies" 33 | last_90_days: "Els darrers 90 dies" 34 | all_time: "Sempre" 35 | custom_range_from: "De" 36 | admin: 37 | name: "Nom" 38 | -------------------------------------------------------------------------------- /config/locales/client.pl_PL.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | pl_PL: 8 | js: 9 | gamification: 10 | you: "Ty" 11 | leaderboard: 12 | link_to_settings: "Ustawienia" 13 | refresh: "Odśwież" 14 | name: "Nazwa" 15 | period: 16 | all_time: "Przez cały czas" 17 | yearly: "Rocznie" 18 | quarterly: "Kwartalnie" 19 | monthly: "Miesięcznie" 20 | weekly: "Tygodniowo" 21 | daily: "Dziennie" 22 | create: "Utwórz" 23 | cancel: "Anuluj" 24 | close: "Zamknij" 25 | delete: "Usuń" 26 | edit: "Edytuj" 27 | back: "Poprzednia" 28 | save: "Zapisz" 29 | apply: "Zastosuj" 30 | update_range: 31 | last_10_days: "Ostatnie 10 dni" 32 | last_30_days: "Ostatnie 30 dni" 33 | last_90_days: "Ostatnie 90 dni" 34 | all_time: "Przez cały czas" 35 | custom_range_from: "Od" 36 | admin: 37 | name: "Imię" 38 | -------------------------------------------------------------------------------- /config/settings.yml: -------------------------------------------------------------------------------- 1 | discourse_gamification: 2 | discourse_gamification_enabled: 3 | default: false 4 | client: true 5 | scorable_categories: 6 | type: category_list 7 | default: "" 8 | like_received_score_value: 9 | default: 1 10 | like_given_score_value: 11 | default: 1 12 | solution_score_value: 13 | default: 10 14 | user_invited_score_value: 15 | default: 10 16 | time_read_score_value: 17 | default: 1 18 | post_read_score_value: 19 | default: 1 20 | topic_created_score_value: 21 | default: 5 22 | post_created_score_value: 23 | default: 2 24 | flag_created_score_value: 25 | default: 10 26 | day_visited_score_value: 27 | default: 1 28 | reaction_received_score_value: 29 | default: 1 30 | reaction_given_score_value: 31 | default: 1 32 | chat_reaction_received_score_value: 33 | default: 1 34 | chat_reaction_given_score_value: 35 | default: 1 36 | chat_message_created_score_value: 37 | default: 1 38 | score_ranking_strategy: 39 | default: dense_rank 40 | type: enum 41 | choices: 42 | - dense_rank 43 | - rank 44 | - row_number 45 | -------------------------------------------------------------------------------- /config/locales/client.ro.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ro: 8 | js: 9 | gamification: 10 | you: "Tu" 11 | leaderboard: 12 | link_to_settings: "Opțiuni" 13 | refresh: "Reîmprospătează" 14 | name: "Nume" 15 | period: 16 | all_time: "Dintotdeauna" 17 | yearly: "Anual" 18 | quarterly: "Trimestrial" 19 | monthly: "Lunar" 20 | weekly: "Săptămânal" 21 | daily: "Zilnic" 22 | create: "Creează" 23 | cancel: "Anulare" 24 | close: "Închide sondajul" 25 | delete: "Șterge" 26 | edit: "Modifică" 27 | back: "Înapoi" 28 | save: "Salvare" 29 | apply: "Aplică" 30 | update_range: 31 | last_10_days: "Ultimele 10 de zile" 32 | last_30_days: "Ultimele 30 de zile" 33 | last_90_days: "Ultimele 90 de zile" 34 | all_time: "Dintotdeauna" 35 | custom_range_from: "De la" 36 | admin: 37 | name: "Nume" 38 | -------------------------------------------------------------------------------- /config/locales/client.el.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | el: 8 | js: 9 | gamification: 10 | you: "Εσείς" 11 | leaderboard: 12 | link_to_settings: "Ρυθμίσεις" 13 | refresh: "Ανανέωση" 14 | name: "Όνομα" 15 | period: 16 | all_time: "Από πάντα" 17 | yearly: "Ετήσια" 18 | quarterly: "Τριμηνιαία" 19 | monthly: "Μηνιαίες" 20 | weekly: "Εβδομαδιαίες" 21 | daily: "Ημερήσιες" 22 | create: "Δημιουργία" 23 | cancel: "Ακύρωση" 24 | close: "Κλείσιμο" 25 | delete: "Σβήσιμο" 26 | edit: "Επεξεργασία" 27 | back: "Πίσω" 28 | save: "Αποθήκευση" 29 | apply: "Εφαρμογή" 30 | update_range: 31 | last_10_days: "Τελευταίες 10 ημέρες" 32 | last_30_days: "Τελευταίες 30 ημέρες" 33 | last_90_days: "Τελευταίες 90 ημέρες" 34 | all_time: "Από πάντα" 35 | custom_range_from: "Από" 36 | admin: 37 | name: "Όνομα" 38 | -------------------------------------------------------------------------------- /spec/models/gamification_score_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | RSpec.describe DiscourseGamification::GamificationScore, type: :model do 6 | fab!(:user) 7 | fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } 8 | 9 | before { DiscourseGamification::LeaderboardCachedView.create_all } 10 | 11 | describe ".calculate_scores" do 12 | it "calculates the scores properly" do 13 | Fabricate.times(10, :topic, user: user) 14 | described_class.calculate_scores 15 | DiscourseGamification::LeaderboardCachedView.refresh_all 16 | expect(user.gamification_score).to eq(50) 17 | 18 | user.topics.take(5).each(&:destroy) 19 | described_class.calculate_scores 20 | DiscourseGamification::LeaderboardCachedView.refresh_all 21 | expect(user.gamification_score).to eq(25) 22 | 23 | # this test covers a bug where scores weren't updated if new score was 0 24 | user.topics.each(&:destroy) 25 | described_class.calculate_scores 26 | DiscourseGamification::LeaderboardCachedView.refresh_all 27 | expect(user.gamification_score).to eq(0) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Civilized Discourse Construction Kit, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /config/locales/client.sk.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sk: 8 | js: 9 | gamification: 10 | you: "Vy" 11 | leaderboard: 12 | link_to_settings: "Nastavenia" 13 | refresh: "Obnoviť" 14 | name: "Meno" 15 | name_placeholder: "Názov..." 16 | period: 17 | all_time: "Za celú dobu" 18 | yearly: "Ročne" 19 | quarterly: "Štvrťročne" 20 | monthly: "Mesačne" 21 | weekly: "Týždenne" 22 | daily: "Denne" 23 | create: "Vytvoriť" 24 | cancel: "Zrušiť" 25 | close: "Zavrieť" 26 | delete: "Odstrániť" 27 | edit: "Upraviť" 28 | back: "Späť" 29 | save: "Uložiť" 30 | apply: "Použi" 31 | update_range: 32 | last_10_days: "Posledných 10 dní" 33 | last_30_days: "Posledných 30 dní" 34 | last_90_days: "Posledných 90 dní" 35 | all_time: "Za celú dobu" 36 | custom_range_from: "Od" 37 | admin: 38 | name: "Meno" 39 | -------------------------------------------------------------------------------- /config/locales/client.lt.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | lt: 8 | js: 9 | gamification: 10 | you: "Jūs" 11 | leaderboard: 12 | link_to_settings: "Nustatymai" 13 | refresh: "Atnaujinti" 14 | name: "Vardas" 15 | period: 16 | all_time: "Per visą laiką" 17 | yearly: "Kasmet" 18 | quarterly: "Kas ketvirtį" 19 | monthly: "Kas mėnesį" 20 | weekly: "Kas savaitę" 21 | daily: "Kasdien" 22 | create: "Sukurti" 23 | cancel: "Atšaukti" 24 | close: "Uždaryti" 25 | delete: "Pašalinti" 26 | edit: "Redaguoti" 27 | back: "Atgal" 28 | save: "Išsaugoti" 29 | apply: "Kandidatuoti" 30 | update_range: 31 | last_10_days: "Paskutinės 10 dienų" 32 | last_30_days: "Paskutinės 30 dienų" 33 | last_90_days: "Paskutinės 90 dienų" 34 | all_time: "Per visą laiką" 35 | custom_range_from: "Nuo" 36 | admin: 37 | name: "Vardas" 38 | -------------------------------------------------------------------------------- /config/locales/client.uk.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | uk: 8 | js: 9 | gamification: 10 | you: "Ви" 11 | leaderboard: 12 | link_to_settings: "Налаштування" 13 | refresh: "Оновити" 14 | name: "Назва" 15 | name_placeholder: "Назва..." 16 | period: 17 | all_time: "Весь час" 18 | yearly: "Щорічно" 19 | quarterly: "Щоквартала" 20 | monthly: "Щомісяця" 21 | weekly: "Щотижня" 22 | daily: "За день" 23 | create: "Створити" 24 | cancel: "Скасувати" 25 | close: "Закрити" 26 | delete: "Видалити" 27 | edit: "Редагувати" 28 | back: "Назад" 29 | save: "Зберегти" 30 | apply: "Застосувати" 31 | update_range: 32 | last_10_days: "Останні 10 днів" 33 | last_30_days: "Останні 30 днів" 34 | last_90_days: "Останні 90 днів" 35 | all_time: "Весь час" 36 | custom_range_from: "Від" 37 | admin: 38 | name: "Назва" 39 | -------------------------------------------------------------------------------- /lib/discourse_gamification/scorables/like_given.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module ::DiscourseGamification 3 | class LikeGiven < Scorable 4 | def self.score_multiplier 5 | SiteSetting.like_given_score_value 6 | end 7 | 8 | def self.category_filter 9 | return "" if scorable_category_list.empty? 10 | 11 | <<~SQL 12 | AND t.category_id IN (#{scorable_category_list}) 13 | SQL 14 | end 15 | 16 | def self.query 17 | <<~SQL 18 | SELECT 19 | pa.user_id AS user_id, 20 | date_trunc('day', pa.created_at) AS date, 21 | COUNT(*) * #{score_multiplier} AS points 22 | FROM 23 | post_actions AS pa 24 | INNER JOIN posts AS p 25 | ON p.id = pa.post_id 26 | INNER JOIN topics AS t 27 | ON t.id = p.topic_id 28 | #{category_filter} 29 | WHERE 30 | p.deleted_at IS NULL AND 31 | t.archetype <> 'private_message' AND 32 | p.wiki IS FALSE AND 33 | post_action_type_id = 2 AND 34 | pa.created_at >= :since 35 | GROUP BY 36 | 1, 2 37 | SQL 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/discourse_gamification/scorables/like_received.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module ::DiscourseGamification 3 | class LikeReceived < Scorable 4 | def self.score_multiplier 5 | SiteSetting.like_received_score_value 6 | end 7 | 8 | def self.category_filter 9 | return "" if scorable_category_list.empty? 10 | 11 | <<~SQL 12 | AND t.category_id IN (#{scorable_category_list}) 13 | SQL 14 | end 15 | 16 | def self.query 17 | <<~SQL 18 | SELECT 19 | p.user_id AS user_id, 20 | date_trunc('day', pa.created_at) AS date, 21 | COUNT(*) * #{score_multiplier} AS points 22 | FROM 23 | post_actions AS pa 24 | INNER JOIN posts AS p 25 | ON p.id = pa.post_id 26 | INNER JOIN topics AS t 27 | ON t.id = p.topic_id 28 | #{category_filter} 29 | WHERE 30 | p.deleted_at IS NULL AND 31 | t.archetype <> 'private_message' AND 32 | p.wiki IS FALSE AND 33 | post_action_type_id = 2 AND 34 | pa.created_at >= :since 35 | GROUP BY 36 | 1, 2 37 | SQL 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /assets/stylesheets/common/leaderboard-minimal.scss: -------------------------------------------------------------------------------- 1 | .leaderboard.-minimal { 2 | .page { 3 | &__header { 4 | display: block; 5 | margin: 0; 6 | border-bottom: 0; 7 | } 8 | } 9 | 10 | .ranking-col-names { 11 | position: relative; 12 | padding: 0.5em; 13 | top: unset; 14 | } 15 | 16 | .ranking-col-names__sticky-border { 17 | display: none; 18 | } 19 | 20 | .user { 21 | padding: 0.75em 0.5em; 22 | margin-bottom: 0; 23 | background-color: transparent; 24 | border-bottom: 1px solid var(--primary-low); 25 | border-radius: 0; 26 | 27 | &.-self { 28 | display: none; 29 | } 30 | 31 | &__rank { 32 | font-size: var(--font-up-3); 33 | 34 | &.-winner { 35 | color: $gold; 36 | } 37 | } 38 | 39 | &__name { 40 | font-size: var(--font-0); 41 | vertical-align: middle; 42 | } 43 | 44 | &__avatar { 45 | img { 46 | margin-right: 0.25em; 47 | } 48 | } 49 | 50 | &__score { 51 | font-size: var(--font-up-1); 52 | } 53 | 54 | &-highlight { 55 | background-color: var(--tertiary-50); 56 | color: var(--primary); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /spec/jobs/delete_leaderboard_positions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe Jobs::DeleteLeaderboardPositions do 6 | fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } 7 | fab!(:score) { Fabricate(:gamification_score, user_id: leaderboard.created_by_id) } 8 | let(:leaderboard_positions) { DiscourseGamification::LeaderboardCachedView.new(leaderboard) } 9 | 10 | before { leaderboard_positions.create } 11 | 12 | it "deletes leaderboard positions" do 13 | expect(leaderboard_positions.scores.length).to eq(1) 14 | 15 | described_class.new.execute(leaderboard_id: leaderboard.id) 16 | 17 | expect { leaderboard_positions.scores }.to raise_error( 18 | DiscourseGamification::LeaderboardCachedView::NotReadyError, 19 | ) 20 | end 21 | 22 | it "deletes leaderboard positions of deleted leaderboards" do 23 | leaderboard.destroy 24 | 25 | expect(leaderboard_positions.scores.length).to eq(1) 26 | 27 | described_class.new.execute(leaderboard_id: leaderboard.id) 28 | 29 | expect { leaderboard_positions.scores }.to raise_error( 30 | DiscourseGamification::LeaderboardCachedView::NotReadyError, 31 | ) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/discourse_gamification/scorables/post_created.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseGamification 4 | class PostCreated < Scorable 5 | def self.score_multiplier 6 | SiteSetting.post_created_score_value 7 | end 8 | 9 | def self.category_filter 10 | return "" if scorable_category_list.empty? 11 | 12 | <<~SQL 13 | AND t.category_id IN (#{scorable_category_list}) 14 | SQL 15 | end 16 | 17 | def self.query 18 | <<~SQL 19 | SELECT 20 | p.user_id AS user_id, 21 | date_trunc('day', p.created_at) AS date, 22 | COUNT(*) * #{score_multiplier} AS points 23 | FROM 24 | posts AS p 25 | INNER JOIN topics AS t 26 | ON t.id = p.topic_id 27 | #{category_filter} 28 | WHERE 29 | p.deleted_at IS NULL AND 30 | t.deleted_at IS NULL AND 31 | t.archetype <> 'private_message' AND 32 | p.post_number <> 1 AND 33 | p.post_type = 1 AND 34 | p.wiki IS FALSE AND 35 | p.hidden IS FALSE AND 36 | p.created_at >= :since 37 | GROUP BY 38 | 1, 2 39 | SQL 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/discourse_gamification/scorables/chat_message_created.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module ::DiscourseGamification 3 | class ChatMessageCreated < Scorable 4 | def self.enabled? 5 | SiteSetting.chat_enabled && score_multiplier > 0 6 | end 7 | 8 | def self.score_multiplier 9 | SiteSetting.chat_message_created_score_value 10 | end 11 | 12 | def self.query 13 | <<~SQL 14 | SELECT 15 | m.user_id, 16 | date_trunc('day', m.created_at) AS date, 17 | COUNT(*) * #{score_multiplier} AS points 18 | FROM 19 | chat_messages AS m 20 | JOIN 21 | chat_channels AS c ON c.id = m.chat_channel_id 22 | LEFT JOIN ( 23 | SELECT direct_message_channel_id 24 | FROM direct_message_users 25 | GROUP BY direct_message_channel_id 26 | HAVING COUNT(DISTINCT user_id) > 1 27 | ) AS dm ON dm.direct_message_channel_id = c.chatable_id 28 | WHERE 29 | m.created_at >= :since AND 30 | m.deleted_at IS NULL AND 31 | (c.chatable_type <> 'DirectMessage' OR dm.direct_message_channel_id IS NOT NULL) 32 | GROUP BY 33 | m.user_id, date_trunc('day', m.created_at) 34 | SQL 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/javascripts/components/gamification-score-test.gjs: -------------------------------------------------------------------------------- 1 | import { render } from "@ember/test-helpers"; 2 | import { module, test } from "qunit"; 3 | import { setupRenderingTest } from "discourse/tests/helpers/component-test"; 4 | import GamificationScore from "../discourse/components/gamification-score"; 5 | 6 | module( 7 | "Discourse Gamification | Component | gamification-score", 8 | function (hooks) { 9 | setupRenderingTest(hooks); 10 | 11 | test("Scores click link to leaderboard", async function (assert) { 12 | this.site.default_gamification_leaderboard_id = 1; 13 | const user = { id: "1", username: "charlie", gamification_score: 1 }; 14 | 15 | await render(); 16 | 17 | assert.dom(".gamification-score a").exists("scores are not clickable"); 18 | }); 19 | 20 | test("Scores show up and are not clickable", async function (assert) { 21 | const user = { id: "1", username: "charlie", gamification_score: 1 }; 22 | 23 | await render(); 24 | 25 | assert.dom(".gamification-score").exists("scores not showing up"); 26 | assert.dom(".gamification-score a").doesNotExist("scores are clickable"); 27 | }); 28 | } 29 | ); 30 | -------------------------------------------------------------------------------- /lib/discourse_gamification/scorables/reaction_given.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module ::DiscourseGamification 3 | class ReactionGiven < Scorable 4 | def self.enabled? 5 | defined?(::DiscourseReactions) && SiteSetting.discourse_reactions_enabled && 6 | score_multiplier > 0 7 | end 8 | 9 | def self.score_multiplier 10 | SiteSetting.reaction_given_score_value 11 | end 12 | 13 | def self.category_filter 14 | return "" if scorable_category_list.empty? 15 | 16 | <<~SQL 17 | AND t.category_id IN (#{scorable_category_list}) 18 | SQL 19 | end 20 | 21 | def self.query 22 | <<~SQL 23 | SELECT 24 | reactions.user_id AS user_id, 25 | date_trunc('day', reactions.created_at) AS date, 26 | COUNT(*) * #{score_multiplier} AS points 27 | FROM 28 | discourse_reactions_reaction_users AS reactions 29 | INNER JOIN posts AS p 30 | ON p.id = reactions.post_id 31 | INNER JOIN topics AS t 32 | ON t.id = p.topic_id 33 | #{category_filter} 34 | WHERE 35 | p.deleted_at IS NULL AND 36 | t.deleted_at IS NULL AND 37 | p.wiki IS FALSE AND 38 | reactions.created_at >= :since 39 | GROUP BY 40 | 1, 2 41 | SQL 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/discourse_gamification/scorables/reaction_received.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module ::DiscourseGamification 3 | class ReactionReceived < Scorable 4 | def self.enabled? 5 | defined?(::DiscourseReactions) && SiteSetting.discourse_reactions_enabled && 6 | score_multiplier > 0 7 | end 8 | 9 | def self.score_multiplier 10 | SiteSetting.reaction_received_score_value 11 | end 12 | 13 | def self.category_filter 14 | return "" if scorable_category_list.empty? 15 | 16 | <<~SQL 17 | AND t.category_id IN (#{scorable_category_list}) 18 | SQL 19 | end 20 | 21 | def self.query 22 | <<~SQL 23 | SELECT 24 | p.user_id AS user_id, 25 | date_trunc('day', reactions.created_at) AS date, 26 | COUNT(*) * #{score_multiplier} AS points 27 | FROM 28 | discourse_reactions_reaction_users AS reactions 29 | INNER JOIN posts AS p 30 | ON p.id = reactions.post_id 31 | INNER JOIN topics AS t 32 | ON t.id = p.topic_id 33 | #{category_filter} 34 | WHERE 35 | p.deleted_at IS NULL AND 36 | t.archetype <> 'private_message' AND 37 | p.wiki IS FALSE AND 38 | reactions.created_at >= :since 39 | GROUP BY 40 | 1, 2 41 | SQL 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/discourse_gamification/scorables/solutions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module ::DiscourseGamification 3 | class Solutions < Scorable 4 | def self.enabled? 5 | defined?(DiscourseSolved) && SiteSetting.solved_enabled && super 6 | end 7 | 8 | def self.score_multiplier 9 | SiteSetting.solution_score_value 10 | end 11 | 12 | def self.category_filter 13 | return "" if scorable_category_list.empty? 14 | 15 | <<~SQL 16 | AND topics.category_id IN (#{scorable_category_list}) 17 | SQL 18 | end 19 | 20 | def self.query 21 | <<~SQL 22 | SELECT 23 | posts.user_id AS user_id, 24 | date_trunc('day', dsst.updated_at) AS date, 25 | COUNT(dsst.topic_id) * #{score_multiplier} AS points 26 | FROM 27 | discourse_solved_solved_topics dsst 28 | INNER JOIN topics 29 | ON dsst.topic_id = topics.id 30 | #{category_filter} 31 | INNER JOIN posts 32 | ON posts.id = dsst.answer_post_id 33 | WHERE 34 | posts.deleted_at IS NULL AND 35 | topics.deleted_at IS NULL AND 36 | topics.archetype <> 'private_message' AND 37 | posts.user_id != topics.user_id AND 38 | dsst.updated_at >= :since 39 | GROUP BY 40 | 1, 2 41 | SQL 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/jobs/update_scores_for_ten_days_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe Jobs::UpdateScoresForTenDays do 6 | let(:user) { Fabricate(:user) } 7 | let(:user_2) { Fabricate(:user) } 8 | let(:post) { Fabricate(:post, user: user) } 9 | let!(:gamification_score) { Fabricate(:gamification_score, user_id: user.id, date: 8.days.ago) } 10 | let!(:gamification_score_2) do 11 | Fabricate(:gamification_score, user_id: user_2.id, date: 12.days.ago) 12 | end 13 | let!(:topic_user_created) { Fabricate(:topic, user: user) } 14 | let!(:topic_user_2_created) { Fabricate(:topic, user: user_2) } 15 | 16 | def run_job 17 | described_class.new.execute 18 | end 19 | 20 | before do 21 | topic_user_created.update(created_at: 8.days.ago) 22 | topic_user_2_created.update(created_at: 12.days.ago) 23 | end 24 | 25 | it "updates all scores within the last 10 days" do 26 | expect(DiscourseGamification::GamificationScore.find_by(user_id: user.id).score).to eq(0) 27 | run_job 28 | expect(DiscourseGamification::GamificationScore.find_by(user_id: user.id).score).to eq(5) 29 | end 30 | 31 | it "does not update scores outside of the last 10 days" do 32 | expect(DiscourseGamification::GamificationScore.find_by(user_id: user_2.id).score).to eq(0) 33 | run_job 34 | expect(DiscourseGamification::GamificationScore.find_by(user_id: user_2.id).score).to eq(0) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/models/gamification_leaderboard_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | RSpec.describe DiscourseGamification::GamificationLeaderboard, type: :model do 6 | fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } 7 | 8 | describe ".resolve_period" do 9 | it "returns default period given a blank period" do 10 | expect(leaderboard.default_period).to eq(0) 11 | expect(leaderboard.resolve_period("")).to eq("all_time") 12 | expect(leaderboard.resolve_period(nil)).to eq("all_time") 13 | leaderboard.default_period = 5 14 | expect(leaderboard.resolve_period(nil)).to eq("daily") 15 | end 16 | 17 | it "returns given period as is if valid" do 18 | described_class.periods.keys.each do |period| 19 | expect(leaderboard.resolve_period(period)).to eq(period) 20 | end 21 | end 22 | 23 | it "returns default period/all_time given an invalid period" do 24 | expect(leaderboard.default_period).to eq(0) 25 | expect(leaderboard.resolve_period("year")).to eq("all_time") 26 | 27 | leaderboard.default_period = 2 28 | expect(leaderboard.default_period).to eq(2) 29 | expect(leaderboard.resolve_period("quart")).to eq("quarterly") 30 | 31 | leaderboard.default_period = -1 32 | expect(leaderboard.default_period).to eq(-1) 33 | expect(leaderboard.resolve_period("invalid")).to eq("all_time") 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/gamification-leaderboard-row.gjs: -------------------------------------------------------------------------------- 1 | import Component from "@ember/component"; 2 | import { tagName } from "@ember-decorators/component"; 3 | import { or } from "truth-helpers"; 4 | import avatar from "discourse/helpers/avatar"; 5 | import number from "discourse/helpers/number"; 6 | import fullnumber from "../helpers/fullnumber"; 7 | 8 | @tagName("") 9 | export default class GamificationLeaderboardRow extends Component { 10 | rank = null; 11 | 12 | 41 | } 42 | -------------------------------------------------------------------------------- /app/controllers/discourse_gamification/gamification_leaderboard_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseGamification 4 | class GamificationLeaderboardController < ::ApplicationController 5 | requires_plugin PLUGIN_NAME 6 | 7 | def respond 8 | discourse_expires_in 1.minute 9 | 10 | default_leaderboard_id = GamificationLeaderboard.first.id 11 | params[:id] ||= default_leaderboard_id 12 | leaderboard = GamificationLeaderboard.find(params[:id]) 13 | 14 | period_param = params[:period] == "all" ? "all_time" : params[:period] 15 | 16 | raise Discourse::NotFound unless @guardian.can_see_leaderboard?(leaderboard) 17 | 18 | render_serialized( 19 | { 20 | leaderboard: leaderboard, 21 | page: params[:page].to_i, 22 | for_user_id: current_user&.id, 23 | period: leaderboard.resolve_period(period_param), 24 | user_limit: params[:user_limit]&.to_i, 25 | }, 26 | LeaderboardViewSerializer, 27 | root: false, 28 | ) 29 | rescue LeaderboardCachedView::NotReadyError => e 30 | Jobs.enqueue(Jobs::GenerateLeaderboardPositions, leaderboard_id: leaderboard.id) 31 | 32 | render json: 33 | LeaderboardSerializer 34 | .new(leaderboard) 35 | .as_json 36 | .merge({ users: [], reason: e.message }), 37 | status: 202 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/system/page_objects/pages/admin_leaderboards.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PageObjects 4 | module Pages 5 | class AdminLeaderboards < PageObjects::Pages::Base 6 | def new_form 7 | @new_form ||= PageObjects::Components::FormKit.new(".new-leaderboard-form") 8 | end 9 | 10 | def full_form 11 | @full_form ||= PageObjects::Components::FormKit.new(".edit-create-leaderboard-form") 12 | end 13 | 14 | def select_included_groups(*groups) 15 | included_groups_sk = 16 | PageObjects::Components::SelectKit.new("#leaderboard-edit__included-groups") 17 | included_groups_sk.expand 18 | groups.each { |g| included_groups_sk.select_row_by_name(g) } 19 | included_groups_sk.collapse 20 | end 21 | 22 | def select_excluded_groups(*groups) 23 | excluded_groups_sk = 24 | PageObjects::Components::SelectKit.new("#leaderboard-edit__excluded-groups") 25 | excluded_groups_sk.expand 26 | groups.each { |g| excluded_groups_sk.select_row_by_name(g) } 27 | excluded_groups_sk.collapse 28 | end 29 | 30 | def edit_leaderboard(leaderboard) 31 | find("#leaderboard-admin__row-#{leaderboard.id} .leaderboard-admin__edit").click 32 | end 33 | 34 | def delete_leaderboard(leaderboard) 35 | find("#leaderboard-admin__row-#{leaderboard.id} .leaderboard-admin__delete").click 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/jobs/update_stale_leaderboard_positions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe Jobs::UpdateStaleLeaderboardPositions do 6 | fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } 7 | fab!(:score) { Fabricate(:gamification_score, user_id: leaderboard.created_by_id) } 8 | let(:leaderboard_positions) { DiscourseGamification::LeaderboardCachedView.new(leaderboard) } 9 | 10 | it "it updates all stale leaderboard positions" do 11 | DiscourseGamification::LeaderboardCachedView.new(leaderboard).create 12 | 13 | expect(leaderboard_positions.scores.length).to eq(1) 14 | expect(leaderboard_positions.scores.first.attributes).to include( 15 | "id" => leaderboard.created_by_id, 16 | "total_score" => 0, 17 | "position" => 1, 18 | ) 19 | 20 | allow_any_instance_of(DiscourseGamification::LeaderboardCachedView).to receive( 21 | :total_scores_query, 22 | ).and_wrap_original do |original_method, period| 23 | "#{original_method.call(period)} \n-- This is a new comment" 24 | end 25 | 26 | expect(leaderboard_positions.stale?).to eq(true) 27 | 28 | described_class.new.execute 29 | 30 | expect(leaderboard_positions.stale?).to eq(false) 31 | expect(leaderboard_positions.scores.length).to eq(1) 32 | expect(leaderboard_positions.scores.first.attributes).to include( 33 | "id" => leaderboard.created_by_id, 34 | "total_score" => 0, 35 | ) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /config/locales/server.zh_CN.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | zh_CN: 8 | site_settings: 9 | discourse_gamification_enabled: "启用 Discourse 游戏化插件" 10 | like_received_score_value: "当用户收到点赞时所获得的点数" 11 | like_given_score_value: "用户每给出一个点赞时所获得的点数" 12 | solution_score_value: "当用户的帖子被标记为解决方案时获得的点数" 13 | user_invited_score_value: "当用户发出的邀请被兑换时所获得的点数" 14 | time_read_score_value: "每花一小时时间阅读所获得的点数" 15 | post_read_score_value: "用户每阅读 100 个帖子所获得的点数" 16 | topic_created_score_value: "当用户创建话题时所获得的点数" 17 | post_created_score_value: "当用户创建帖子时所获得的点数" 18 | flag_created_score_value: "当用户举报帖子并且该举报被管理人员接受时所获得的点数" 19 | day_visited_score_value: "用户每天访问站点所获得的点数" 20 | scorable_categories: "操作将生成点数的类别列表。留空以在所有类别上启用点数。" 21 | reaction_received_score_value: "当用户收到回应时所获得的点数" 22 | reaction_given_score_value: "用户每给出一个回应时所获得的点数" 23 | chat_reaction_received_score_value: "当用户收到聊天消息的回应时所获得的点数" 24 | chat_reaction_given_score_value: "用户每给出一个聊天消息的回应时所获得的点数" 25 | chat_message_created_score_value: "用户在聊天中发送的每条消息所获得的点数" 26 | score_ranking_strategy: "排行榜位置排名策略" 27 | score: "点数" 28 | default_leaderboard_name: "全局排行榜" 29 | rate_limiter: 30 | by_type: 31 | recalculate_scores_remaining: "您已达到重新计算分数的最大次数。请等待 %{time_left}后再试。" 32 | errors: 33 | leaderboard_positions_not_ready: "我们正在生成您的排行榜。请几分钟后再试。" 34 | -------------------------------------------------------------------------------- /test/javascripts/components/minimal-gamification-leaderboard-test.gjs: -------------------------------------------------------------------------------- 1 | import { render } from "@ember/test-helpers"; 2 | import { module, test } from "qunit"; 3 | import { setupRenderingTest } from "discourse/tests/helpers/component-test"; 4 | import pretender, { response } from "discourse/tests/helpers/create-pretender"; 5 | import MinimalGamificationLeaderboard from "../discourse/components/minimal-gamification-leaderboard"; 6 | 7 | module( 8 | "Discourse Gamification | Component | minimal-gamification-leaderboard", 9 | function (hooks) { 10 | setupRenderingTest(hooks); 11 | 12 | test("regular leaderboard endpoint", async function (assert) { 13 | pretender.get("/leaderboard", () => 14 | response({ 15 | leaderboard: "", 16 | personal: "", 17 | users: [{ id: 1, username: "foo" }], 18 | }) 19 | ); 20 | 21 | await render(); 22 | 23 | assert.dom(".user__name").hasText("foo"); 24 | }); 25 | 26 | test("leaderboard by id and with custom user count", async function (assert) { 27 | pretender.get("/leaderboard/3", ({ queryParams }) => { 28 | assert.strictEqual(queryParams.user_limit, "5"); 29 | 30 | return response({ 31 | leaderboard: "", 32 | personal: "", 33 | users: [{ id: 1, username: "foo" }], 34 | }); 35 | }); 36 | 37 | await render( 38 | 41 | ); 42 | 43 | assert.dom(".user__name").hasText("foo"); 44 | }); 45 | } 46 | ); 47 | -------------------------------------------------------------------------------- /config/locales/server.sv.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sv: 8 | site_settings: 9 | discourse_gamification_enabled: "Aktivera insticksprogrammet Discourse Gamification" 10 | like_received_score_value: "Värdet på hurrarop som tilldelas när en användare får en gillning" 11 | like_given_score_value: "Värdet på hurrarop som tilldelas för varje gillning en användare ger" 12 | solution_score_value: "Värdet på hurrarop som tilldelas när en användares inlägg markeras som en lösning" 13 | user_invited_score_value: "Värdet på hurrarop som tilldelas när en användare har en inlöst inbjudan" 14 | time_read_score_value: "Värdet på hurrarop som delas ut för varje timmes läsningstid" 15 | post_read_score_value: "Värdet på hurrarop som tilldelas för varje hundra inlägg en användare läser" 16 | topic_created_score_value: "Värdet på hurrarop som tilldelas när en användare skapar ett ämne" 17 | post_created_score_value: "Värdet på hurrarop som tilldelas när en användare skapar ett inlägg" 18 | flag_created_score_value: "Värdet på hurrarop som tilldelas när en användare flaggar ett inlägg och den flaggan accepteras av en personalanvändare" 19 | day_visited_score_value: "Värdet på hurrarop som tilldelas för varje dag en användare besöker webbplatsen" 20 | scorable_categories: "Lista över kategorier där åtgärder kommer att generera jubel. Lämna tomt för att aktivera hejarop i alla kategorier" 21 | score: "Hurra" 22 | default_leaderboard_name: "Global topplista" 23 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/models/gamification-leaderboard.js: -------------------------------------------------------------------------------- 1 | import { tracked } from "@glimmer/tracking"; 2 | import { i18n } from "discourse-i18n"; 3 | import { LEADERBOARD_PERIODS } from "discourse/plugins/discourse-gamification/discourse/components/gamification-leaderboard"; 4 | 5 | export default class GamificationLeaderboard { 6 | static create(args = {}) { 7 | return new GamificationLeaderboard(args); 8 | } 9 | 10 | @tracked id; 11 | @tracked createdAt; 12 | @tracked updatedAt; 13 | @tracked createdById; 14 | @tracked excludedGroupsIds; 15 | @tracked includedGroupsIds; 16 | @tracked visibleToGroupsIds; 17 | @tracked forCategoryId; 18 | @tracked fromDate; 19 | @tracked toDate; 20 | @tracked name; 21 | @tracked period; 22 | @tracked periodFilterDisabled; 23 | 24 | constructor(args = {}) { 25 | this.id = args.id; 26 | this.createdAt = args.created_at; 27 | this.updatedAt = args.updated_at; 28 | this.createdById = args.created_by_id; 29 | this.excludedGroupsIds = args.excluded_groups_ids; 30 | this.includedGroupsIds = args.included_groups_ids; 31 | this.visibleToGroupsIds = args.visible_to_groups_ids; 32 | this.forCategoryId = args.for_category_id; 33 | this.fromDate = args.from_date; 34 | this.toDate = args.to_date; 35 | this.name = args.name; 36 | this.period = args.period; 37 | this.periodFilterDisabled = args.period_filter_disabled; 38 | 39 | if (Number.isInteger(args.default_period)) { 40 | this.defaultPeriod = i18n( 41 | `gamification.leaderboard.period.${ 42 | LEADERBOARD_PERIODS[args.default_period] 43 | }` 44 | ); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test/javascripts/components/gamification-leaderboard-row-test.gjs: -------------------------------------------------------------------------------- 1 | import { render } from "@ember/test-helpers"; 2 | import { module, test } from "qunit"; 3 | import { setupRenderingTest } from "discourse/tests/helpers/component-test"; 4 | import GamificationLeaderboardRow from "../discourse/components/gamification-leaderboard-row"; 5 | 6 | module( 7 | "Discourse Gamification | Component | gamification-leaderboard-row", 8 | function (hooks) { 9 | setupRenderingTest(hooks); 10 | 11 | test("Display name prioritizes name", async function (assert) { 12 | this.siteSettings.prioritize_username_in_ux = false; 13 | const rank = { username: "id", name: "bob" }; 14 | 15 | await render( 16 | 17 | ); 18 | 19 | assert.dom(".user__name").hasText("bob"); 20 | }); 21 | 22 | test("Display name prioritizes username", async function (assert) { 23 | this.siteSettings.prioritize_username_in_ux = true; 24 | const rank = { username: "id", name: "bob" }; 25 | 26 | await render( 27 | 28 | ); 29 | 30 | assert.dom(".user__name").hasText("id"); 31 | }); 32 | 33 | test("Display name prioritizes username when name is empty", async function (assert) { 34 | this.siteSettings.prioritize_username_in_ux = false; 35 | const rank = { username: "id", name: "" }; 36 | 37 | await render( 38 | 39 | ); 40 | 41 | assert.dom(".user__name").hasText("id"); 42 | }); 43 | } 44 | ); 45 | -------------------------------------------------------------------------------- /config/locales/server.hr.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hr: 8 | site_settings: 9 | discourse_gamification_enabled: "Omogućite dodatak za gamificiranje diskursa" 10 | like_received_score_value: "Vrijednost navijanja koja se dodjeljuje kada korisnik dobije lajk" 11 | like_given_score_value: "Vrijednost navijanja koja se dodjeljuje za svaki lajk koji korisnik daje" 12 | solution_score_value: "Vrijednost navijanja koja se dodjeljuje kada se objava korisnika označi kao rješenje" 13 | user_invited_score_value: "Vrijednost navijanja koja se dodjeljuje kada korisnik iskoristi pozivnicu" 14 | time_read_score_value: "Vrijednost navijanja koja se dodjeljuje za svaki sat vremena provedenog u čitanju" 15 | post_read_score_value: "Vrijednost navijanja koja se dodjeljuje za svakih sto postova koje korisnik pročita" 16 | topic_created_score_value: "Vrijednost navijanja koja se dodjeljuje kada korisnik kreira temu" 17 | post_created_score_value: "Vrijednost navijanja koja se dodjeljuje kada korisnik kreira objavu" 18 | flag_created_score_value: "Vrijednost navijanja koja se dodjeljuje kada korisnik označi objavu i tu zastavu prihvaća korisnik osoblja" 19 | day_visited_score_value: "Vrijednost navijanja koji se dodjeljuje za svaki dan kada korisnik posjeti web stranicu" 20 | scorable_categories: "Popis kategorija u kojima će akcije generirati veselje. Ostavite prazno kako biste omogućili navijanje za sve kategorije" 21 | score: "Živjeli" 22 | default_leaderboard_name: "Globalna ploča s najboljim rezultatima" 23 | -------------------------------------------------------------------------------- /config/locales/server.ja.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ja: 8 | site_settings: 9 | discourse_gamification_enabled: "Discourse Gamification プラグインを有効にする" 10 | like_received_score_value: "ユーザーが「いいね!」を受け取った時に付与される拍手の値" 11 | like_given_score_value: "ユーザーが与える「いいね!」ごとに付与される拍手の値" 12 | solution_score_value: "ユーザーの投稿が解決策としてマークされたときに付与される拍手の値" 13 | user_invited_score_value: "ユーザーに招待の引き換えがあった時に付与される拍手の値" 14 | time_read_score_value: "閲覧に費やした時間ごとに付与される拍手の値" 15 | post_read_score_value: "ユーザーが投稿を 100 件読むたびに付与される拍手の値" 16 | topic_created_score_value: "ユーザーがトピックを作成した時に付与される拍手の値" 17 | post_created_score_value: "ユーザーが投稿を作成した時に付与される拍手の値" 18 | flag_created_score_value: "ユーザーが投稿を通報し、その通報がスタッフユーザーに承認された時に付与される拍手の値" 19 | day_visited_score_value: "ユーザーがサイトにアクセスする日ごとに付与される拍手の値" 20 | scorable_categories: "アクションによって拍手が生成されるカテゴリのリスト。全カテゴリで拍手を有効にする場合は、空白のままにします。" 21 | reaction_received_score_value: "ユーザーがリアクションを受けた時に付与される拍手の値" 22 | reaction_given_score_value: "ユーザーがリアクションするたびに付与される拍手の値" 23 | chat_reaction_received_score_value: "ユーザーがチャットメッセージでリアクションを受けた時に付与される拍手の値" 24 | chat_reaction_given_score_value: "ユーザーがチャットメッセージにリアクションするたびに付与される拍手の値" 25 | chat_message_created_score_value: "ユーザーがチャットでメッセージを送信するたびに付与される拍手の値" 26 | score_ranking_strategy: "リーダーボード順位のランク付け戦略" 27 | score: "拍手" 28 | default_leaderboard_name: "グローバルリーダーボード" 29 | rate_limiter: 30 | by_type: 31 | recalculate_scores_remaining: "スコアの再計算回数の上限に達しました。%{time_left}経ってから、もう一度お試しください。" 32 | errors: 33 | leaderboard_positions_not_ready: "リーダーボードを生成しています。数分後にもう一度お試しください。" 34 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/minimal-gamification-leaderboard-row.gjs: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { service } from "@ember/service"; 3 | import { or } from "truth-helpers"; 4 | import avatar from "discourse/helpers/avatar"; 5 | import concatClass from "discourse/helpers/concat-class"; 6 | import icon from "discourse/helpers/d-icon"; 7 | import number from "discourse/helpers/number"; 8 | import { i18n } from "discourse-i18n"; 9 | import sum from "../helpers/sum"; 10 | 11 | export default class MinimalGamificationLeaderboardRow extends Component { 12 | @service siteSettings; 13 | 14 | 51 | } 52 | -------------------------------------------------------------------------------- /app/controllers/discourse_gamification/admin_gamification_score_event_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DiscourseGamification::AdminGamificationScoreEventController < Admin::AdminController 4 | requires_plugin DiscourseGamification::PLUGIN_NAME 5 | 6 | def show 7 | params.permit(%i[id user_id date]) 8 | 9 | events = DiscourseGamification::GamificationScoreEvent.limit(100) 10 | events = events.where(id: params[:id]) if params[:id] 11 | events = events.where(user_id: params[:user_id]) if params[:user_id] 12 | events = events.where(date: params[:date]) if params[:date] 13 | 14 | raise Discourse::NotFound unless events 15 | 16 | render_serialized({ events: events }, AdminGamificationScoreEventIndexSerializer, root: false) 17 | end 18 | 19 | def create 20 | params.require(%i[user_id date points]) 21 | params.permit(:description) 22 | 23 | event = 24 | DiscourseGamification::GamificationScoreEvent.new( 25 | user_id: params[:user_id], 26 | date: params[:date], 27 | points: params[:points], 28 | description: params[:description], 29 | ) 30 | 31 | if event.save 32 | render_serialized(event, AdminGamificationScoreEventSerializer, root: false) 33 | else 34 | render_json_error(event) 35 | end 36 | end 37 | 38 | def update 39 | params.require(%i[id points]) 40 | params.permit(:description) 41 | 42 | event = DiscourseGamification::GamificationScoreEvent.find(params[:id]) 43 | raise Discourse::NotFound unless event 44 | 45 | event.update(points: params[:points], description: params[:description] || event.description) 46 | 47 | if event.save 48 | render json: success_json 49 | else 50 | render_json_error(event) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/javascripts/components/minimal-gamification-leaderboard-row-test.gjs: -------------------------------------------------------------------------------- 1 | import { render } from "@ember/test-helpers"; 2 | import { module, test } from "qunit"; 3 | import { setupRenderingTest } from "discourse/tests/helpers/component-test"; 4 | import MinimalGamificationLeaderboardRow from "../discourse/components/minimal-gamification-leaderboard-row"; 5 | 6 | module( 7 | "Discourse Gamification | Component | minimal-gamification-leaderboard-row", 8 | function (hooks) { 9 | setupRenderingTest(hooks); 10 | 11 | test("Display name prioritizes name", async function (assert) { 12 | this.siteSettings.prioritize_username_in_ux = false; 13 | const rank = { username: "id", name: "bob" }; 14 | 15 | await render( 16 | 19 | ); 20 | 21 | assert.dom(".user__name").hasText("bob"); 22 | }); 23 | 24 | test("Display name prioritizes username", async function (assert) { 25 | this.siteSettings.prioritize_username_in_ux = true; 26 | const rank = { username: "id", name: "bob" }; 27 | 28 | await render( 29 | 32 | ); 33 | 34 | assert.dom(".user__name").hasText("id"); 35 | }); 36 | 37 | test("Display name prioritizes username when name is empty", async function (assert) { 38 | this.siteSettings.prioritize_username_in_ux = false; 39 | const rank = { username: "id", name: "" }; 40 | 41 | await render( 42 | 45 | ); 46 | 47 | assert.dom(".user__name").hasText("id"); 48 | }); 49 | } 50 | ); 51 | -------------------------------------------------------------------------------- /config/locales/client.fa_IR.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | fa_IR: 8 | js: 9 | gamification_score: "امتیازات" 10 | gamification: 11 | score: "امتیازات" 12 | you: "شما" 13 | leaderboard: 14 | title: "تابلو امتیازات" 15 | info: "این چطور کار می‌کنه؟" 16 | link_to_settings: "تنظیمات" 17 | refresh: "تازه‌سازی" 18 | modal: 19 | title: "تابلو امتیازات چطور کار می‌کنه؟" 20 | name: "نام" 21 | name_placeholder: "نام..." 22 | confirm_destroy: "آیا مطمئنید که می‌خواهید این تابلوی امتیاز را حذف کنید؟" 23 | date: 24 | range: "از / تا محدوده تاریخ" 25 | default_period: "بازه زمانی پیش‌فرض" 26 | default_period_help: "بازه زمانی پیش‌فرض را برای نمایش این تابلوی امتیازات تنظیم کنید." 27 | period: 28 | all_time: "همیشه" 29 | yearly: "سالیانه " 30 | quarterly: "فصلی" 31 | monthly: "ماهیانه" 32 | weekly: "هفتگی" 33 | daily: "روزانه" 34 | rank: "رتبه" 35 | create: "ایجاد" 36 | cancel: "لغو" 37 | close: "بستن" 38 | delete: "حذف" 39 | edit: "ویرایش" 40 | back: "بازگشت" 41 | save: "ذخیره" 42 | apply: "اعمال کردن" 43 | update_range: 44 | last_10_days: "۱۰ روز گذشته" 45 | last_30_days: "۳۰ روز گذشته" 46 | last_90_days: "۹۰ روز گذشته" 47 | last_year: "سال گذشته" 48 | all_time: "همیشه" 49 | custom_date_range: "محدوده سفارشی" 50 | custom_range_from: "از طرف" 51 | admin: 52 | title: "بازی‌وارسازی" 53 | name: "نام" 54 | period: "دوره زمانی" 55 | -------------------------------------------------------------------------------- /config/locales/client.hr.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hr: 8 | js: 9 | gamification_score: "Živjeli" 10 | gamification: 11 | score: "Živjeli" 12 | you: "Vi" 13 | leaderboard: 14 | title: "Ljestvice članova" 15 | info: "Kako ovo radi?" 16 | link_to_settings: "Postavke" 17 | refresh: "Osvježi" 18 | modal: 19 | title: "Kako radi ploča s najboljim rezultatima?" 20 | text: "Bodovi se dodjeljuju za interakciju sa zajednicom, kao što su posjete, sviđanje i objavljivanje. Vaš rezultat se ažurira svakih nekoliko minuta. Zato budite od pomoći, aktivni i podržavajte i napredujte kroz činove!" 21 | name: "Ime" 22 | name_placeholder: "Ime..." 23 | confirm_destroy: "Jeste li sigurni da želite izbrisati ovu ploču s najboljim rezultatima?" 24 | date: 25 | range: "Od/do datumskog raspona" 26 | period: 27 | all_time: "Oduvijek" 28 | yearly: "Godišnje" 29 | quarterly: "Tromjesečno" 30 | monthly: "Mjesečno" 31 | weekly: "Tjedno" 32 | daily: "Dnevno" 33 | create: "Stvorite" 34 | cancel: "Odustani" 35 | close: "Zatvori" 36 | delete: "Izbriši" 37 | edit: "Uredi" 38 | back: "Natrag" 39 | save: "Spremi" 40 | apply: "Primijeni" 41 | update_range: 42 | last_10_days: "Zadnjih 10 dana" 43 | last_30_days: "Zadnjih 30 dana" 44 | last_90_days: "Zadnjih 90 dana" 45 | all_time: "Oduvijek" 46 | custom_range_from: "Od" 47 | admin: 48 | title: "Gamifikacija" 49 | name: "Ime i prezime" 50 | -------------------------------------------------------------------------------- /spec/lib/scorables/solutions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe DiscourseGamification::Solutions do 4 | fab!(:category) 5 | fab!(:topic) { Fabricate(:topic, category: category) } 6 | fab!(:question_user) { Fabricate(:user) } 7 | fab!(:answer_user) { Fabricate(:user) } 8 | fab!(:answer_post) { Fabricate(:post, topic: topic, user: answer_user) } 9 | 10 | before { SiteSetting.solution_score_value = 5 } 11 | 12 | it "is enabled when score value is positive" do 13 | expect(described_class).to be_enabled 14 | 15 | SiteSetting.solution_score_value = 0 16 | expect(described_class).not_to be_enabled 17 | end 18 | 19 | describe "scoring query" do 20 | def query_results 21 | DB.query(described_class.query, since: 2.days.ago) 22 | end 23 | 24 | it "scores accepted answers correctly" do 25 | freeze_time DateTime.parse("2024-01-01 12:00") 26 | 27 | DiscourseSolved.accept_answer!(answer_post, Discourse.system_user) 28 | 29 | expect(query_results).to contain_exactly( 30 | have_attributes(user_id: answer_user.id, date: Time.current.beginning_of_day, points: 5.0), 31 | ) 32 | 33 | DiscourseSolved.unaccept_answer!(answer_post, topic:) 34 | expect(query_results).to be_empty 35 | end 36 | 37 | it "doesn't score self-accepted answers" do 38 | topic.update!(user: answer_user) 39 | DiscourseSolved.accept_answer!(answer_post, Discourse.system_user) 40 | 41 | expect(query_results).to be_empty 42 | end 43 | end 44 | 45 | it "is disabled when solved plugin is disabled" do 46 | SiteSetting.solved_enabled = false 47 | expect(described_class).not_to be_enabled 48 | 49 | SiteSetting.solved_enabled = true 50 | SiteSetting.solution_score_value = 0 51 | expect(described_class).not_to be_enabled 52 | 53 | SiteSetting.solution_score_value = 1 54 | expect(described_class).to be_enabled 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/javascripts/components/gamification-leaderboard-test.gjs: -------------------------------------------------------------------------------- 1 | import { render } from "@ember/test-helpers"; 2 | import { module, test } from "qunit"; 3 | import { setupRenderingTest } from "discourse/tests/helpers/component-test"; 4 | import GamificationLeaderboard from "../discourse/components/gamification-leaderboard"; 5 | 6 | module( 7 | "Discourse Gamification | Component | gamification-leaderboard", 8 | function (hooks) { 9 | setupRenderingTest(hooks); 10 | 11 | test("Display name prioritizes name", async function (assert) { 12 | this.siteSettings.prioritize_username_in_ux = false; 13 | const model = { 14 | leaderboard: "", 15 | personal: "", 16 | users: [{ username: "id", name: "bob" }], 17 | }; 18 | 19 | await render( 20 | 21 | ); 22 | 23 | assert.dom(".winner__name").hasText("bob"); 24 | }); 25 | 26 | test("Display name prioritizes username", async function (assert) { 27 | this.siteSettings.prioritize_username_in_ux = true; 28 | const model = { 29 | leaderboard: "", 30 | personal: "", 31 | users: [{ username: "id", name: "bob" }], 32 | }; 33 | 34 | await render( 35 | 36 | ); 37 | 38 | assert.dom(".winner__name").hasText("id"); 39 | }); 40 | 41 | test("Display name prioritizes username when name is empty", async function (assert) { 42 | this.siteSettings.prioritize_username_in_ux = false; 43 | const model = { 44 | leaderboard: "", 45 | personal: "", 46 | users: [{ username: "id", name: "" }], 47 | }; 48 | 49 | await render( 50 | 51 | ); 52 | 53 | assert.dom(".winner__name").hasText("id"); 54 | }); 55 | } 56 | ); 57 | -------------------------------------------------------------------------------- /test/javascripts/acceptance/gamification-score-test.js: -------------------------------------------------------------------------------- 1 | import { click, visit } from "@ember/test-helpers"; 2 | import { test } from "qunit"; 3 | import { cloneJSON } from "discourse/lib/object"; 4 | import userFixtures from "discourse/tests/fixtures/user-fixtures"; 5 | import { fixturesByUrl } from "discourse/tests/helpers/create-pretender"; 6 | import { acceptance } from "discourse/tests/helpers/qunit-helpers"; 7 | 8 | acceptance( 9 | "Discourse Gamification | User Card | Show Gamification Score", 10 | function (needs) { 11 | needs.user(); 12 | needs.pretender((server, helper) => { 13 | const cardResponse = cloneJSON(userFixtures["/u/charlie/card.json"]); 14 | cardResponse.user.gamification_score = 10; 15 | server.get("/u/charlie/card.json", () => helper.response(cardResponse)); 16 | }); 17 | 18 | test("user card gamification score - score is present", async function (assert) { 19 | await visit("/t/internationalization-localization/280"); 20 | await click(".topic-map__users-trigger"); 21 | await click('a[data-user-card="charlie"]'); 22 | 23 | assert 24 | .dom(".user-card .gamification-score") 25 | .hasText("Cheers 10", "user card has gamification score"); 26 | }); 27 | } 28 | ); 29 | 30 | acceptance( 31 | "Discourse Gamification | User Metadata | Show Gamification Score", 32 | function (needs) { 33 | needs.user(); 34 | needs.pretender((server, helper) => { 35 | const userResponse = cloneJSON(fixturesByUrl["/u/charlie.json"]); 36 | userResponse.user.gamification_score = 10; 37 | 38 | server.get("/u/charlie.json", () => helper.response(userResponse)); 39 | }); 40 | 41 | test("user profile gamification score - score is present", async function (assert) { 42 | await visit("/u/charlie/summary"); 43 | 44 | assert 45 | .dom(".details .secondary .gamification-score") 46 | .hasText("10", "user metadata has gamification score"); 47 | }); 48 | } 49 | ); 50 | -------------------------------------------------------------------------------- /lib/discourse_gamification/directory_integration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseGamification 4 | class DirectoryIntegration 5 | def self.query 6 | <<~SQL 7 | WITH default_leaderboard AS ( 8 | SELECT 9 | from_date, 10 | to_date 11 | FROM 12 | gamification_leaderboards 13 | ORDER BY 14 | id ASC 15 | LIMIT 1 16 | ), total_score AS ( 17 | SELECT 18 | user_id, 19 | SUM(score) AS score 20 | FROM 21 | gamification_scores 22 | LEFT JOIN 23 | default_leaderboard ON true 24 | WHERE 25 | date >= :since 26 | AND 27 | ( 28 | ( 29 | default_leaderboard.from_date IS NULL 30 | OR 31 | date >= default_leaderboard.from_date 32 | ) 33 | AND 34 | ( 35 | default_leaderboard.to_date IS NULL 36 | OR 37 | date <= default_leaderboard.to_date 38 | ) 39 | ) 40 | GROUP BY 41 | 1 42 | ), scored_directory AS ( 43 | SELECT 44 | directory_items.user_id, 45 | COALESCE(total_score.score, 0) AS score 46 | FROM 47 | directory_items 48 | LEFT JOIN 49 | total_score ON total_score.user_id = directory_items.user_id 50 | WHERE 51 | directory_items.period_type = :period_type 52 | ) 53 | UPDATE 54 | directory_items 55 | SET 56 | gamification_score = scored_directory.score 57 | FROM 58 | scored_directory 59 | WHERE 60 | scored_directory.user_id = directory_items.user_id AND 61 | directory_items.period_type = :period_type AND 62 | scored_directory.score != directory_items.gamification_score 63 | SQL 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /config/locales/server.he.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | he: 8 | site_settings: 9 | discourse_gamification_enabled: "הפעלת תוסף המשחוק של Discourse" 10 | like_received_score_value: "ערך התשועה שמוענק כשמשתמש מקבל לייק" 11 | like_given_score_value: "ערך התשועה שמוענק עבור כל לייק שמשתמש נותן" 12 | solution_score_value: "ערך התשועה שמוענק כאשר פוסט של משתמש מסומן כפתרון" 13 | user_invited_score_value: "ערך התשועה שמוענק כאשר משתמש מקבל הזמנה שסולקה" 14 | time_read_score_value: "ערך התשועה שמוענק עבור כל שעת קריאה" 15 | post_read_score_value: "ערך התשועה שמוענק עבור כל מאה פוסטים שמשתמש קורא" 16 | topic_created_score_value: "ערך התשועה שמוענק כאשר משתמש יוצר נושא" 17 | post_created_score_value: "ערך התשועה שמוענק כאשר משתמש יוצר פוסט" 18 | flag_created_score_value: "ערך התשועה שמוענק כאשר משתמש מסמן פוסט בדגל והדגל מתקבל על ידי משתמש צוות" 19 | day_visited_score_value: "ערך התשועה שמוענק עבור כל יום בו משתמש מבקר באתר" 20 | scorable_categories: "רשימת הקטגוריות בהן פעולות מייצרות תשועות. יש להשאיר ריק כדי להפעיל תשועות בכל הקטגוריות" 21 | reaction_received_score_value: "ערך התשועה שמוענק כשמשתמש מקבל רגש" 22 | reaction_given_score_value: "ערך התשועה שמוענק עבור כל רגש שמשתמש נותן" 23 | chat_reaction_received_score_value: "ערך התשועה שמוענק כשמשתמש מקבל תגובה להודעה בצ׳אט" 24 | chat_reaction_given_score_value: "ערך התשועה שמוענק עבור כל רגש שמשתמש הוסיף להודעת צ׳אט" 25 | chat_message_created_score_value: "ערך התשועה שמוענק עבור כל הודעה שמשתמש שולח בצ׳אט" 26 | score_ranking_strategy: "אסטרטגיית דירוג מיקום בלוח התוצאות" 27 | score: "תשועות" 28 | default_leaderboard_name: "לוח תוצאות עולמי" 29 | rate_limiter: 30 | by_type: 31 | recalculate_scores_remaining: "הגעת למספר החישובים מחדש לניקוד המרבי. נא להמתין %{time_left} בטרם ביצוע ניסיון חוזר." 32 | errors: 33 | leaderboard_positions_not_ready: "אנו מייצרים את לוח התוצאות שלך. נא לנסות שוב בעוד כמה דקות." 34 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | DiscourseGamification::Engine.routes.draw do 4 | get "/" => "gamification_leaderboard#respond" 5 | get "/:id" => "gamification_leaderboard#respond" 6 | end 7 | 8 | Discourse::Application.routes.draw do 9 | mount ::DiscourseGamification::Engine, at: "/leaderboard" 10 | 11 | scope "/admin/plugins/discourse-gamification", constraints: StaffConstraint.new do 12 | get "/leaderboards" => "discourse_gamification/admin_gamification_leaderboard#index" 13 | get "/leaderboards/:id" => "discourse_gamification/admin_gamification_leaderboard#show" 14 | end 15 | 16 | get "/admin/plugins/gamification" => 17 | "discourse_gamification/admin_gamification_leaderboard#index", 18 | :constraints => StaffConstraint.new 19 | post "/admin/plugins/gamification/leaderboard" => 20 | "discourse_gamification/admin_gamification_leaderboard#create", 21 | :constraints => StaffConstraint.new 22 | put "/admin/plugins/gamification/leaderboard/:id" => 23 | "discourse_gamification/admin_gamification_leaderboard#update", 24 | :constraints => StaffConstraint.new 25 | delete "/admin/plugins/gamification/leaderboard/:id" => 26 | "discourse_gamification/admin_gamification_leaderboard#destroy", 27 | :constraints => StaffConstraint.new 28 | put "/admin/plugins/gamification/recalculate-scores" => 29 | "discourse_gamification/admin_gamification_leaderboard#recalculate_scores", 30 | :constraints => StaffConstraint.new, 31 | :as => :recalculate_scores 32 | end 33 | 34 | Discourse::Application.routes.draw do 35 | get "/admin/plugins/gamification/score_events" => 36 | "discourse_gamification/admin_gamification_score_event#show", 37 | :constraints => StaffConstraint.new 38 | post "/admin/plugins/gamification/score_events" => 39 | "discourse_gamification/admin_gamification_score_event#create", 40 | :constraints => StaffConstraint.new 41 | put "/admin/plugins/gamification/score_events" => 42 | "discourse_gamification/admin_gamification_score_event#update", 43 | :constraints => StaffConstraint.new 44 | end 45 | -------------------------------------------------------------------------------- /admin/assets/javascripts/discourse/controllers/admin-plugins-show-discourse-gamification-leaderboards-index.js: -------------------------------------------------------------------------------- 1 | import Controller from "@ember/controller"; 2 | import { action } from "@ember/object"; 3 | import { service } from "@ember/service"; 4 | import { ajax } from "discourse/lib/ajax"; 5 | import { popupAjaxError } from "discourse/lib/ajax-error"; 6 | import discourseComputed from "discourse/lib/decorators"; 7 | import { i18n } from "discourse-i18n"; 8 | import RecalculateScoresForm from "discourse/plugins/discourse-gamification/discourse/components/modal/recalculate-scores-form"; 9 | 10 | export default class AdminPluginsShowDiscourseGamificationLeaderboardsIndexController extends Controller { 11 | @service modal; 12 | @service dialog; 13 | @service toasts; 14 | 15 | creatingNew = false; 16 | 17 | @discourseComputed("model.leaderboards.@each.updatedAt") 18 | sortedLeaderboards(leaderboards) { 19 | return leaderboards?.sortBy("updatedAt").reverse() || []; 20 | } 21 | 22 | @action 23 | resetNewLeaderboard() { 24 | this.set("creatingNew", false); 25 | } 26 | 27 | @action 28 | destroyLeaderboard(leaderboard) { 29 | this.dialog.deleteConfirm({ 30 | message: i18n("gamification.leaderboard.confirm_destroy"), 31 | didConfirm: () => { 32 | return ajax( 33 | `/admin/plugins/gamification/leaderboard/${leaderboard.id}`, 34 | { 35 | type: "DELETE", 36 | } 37 | ) 38 | .then(() => { 39 | this.toasts.success({ 40 | duration: 3000, 41 | data: { 42 | message: i18n("gamification.leaderboard.delete_success"), 43 | }, 44 | }); 45 | this.model.leaderboards.removeObject(leaderboard); 46 | }) 47 | .catch(popupAjaxError); 48 | }, 49 | }); 50 | } 51 | 52 | @action 53 | recalculateScores() { 54 | this.modal.show(RecalculateScoresForm, { 55 | model: this.model, 56 | }); 57 | } 58 | 59 | parseDate(date) { 60 | if (date) { 61 | // using the format YYYY-MM-DD returns the previous day for some timezones 62 | return date.replace(/-/g, "/"); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /config/locales/server.ar.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ar: 8 | site_settings: 9 | discourse_gamification_enabled: "تفعيل المكوِّن الإضافي لتلعيب Discourse" 10 | like_received_score_value: "قيمة الهتافات الممنوح عند تلقي المستخدم إعجابًا" 11 | like_given_score_value: "قيمة الهتافات الممنوح لكل إعجاب يمنحه المستخدم" 12 | solution_score_value: "قيمة الهتافات الممنوح عند وضع علامة على منشور المستخدم كحل" 13 | user_invited_score_value: "قيمة الهتافات الممنوح عند استخدام دعوة من المستخدم" 14 | time_read_score_value: "قيمة الهتافات الممنوح لكل ساعة قراءة" 15 | post_read_score_value: "قيمة الهتافات الممنوح لكل مئة منشور يقرأه المستخدم" 16 | topic_created_score_value: "قيمة الهتافات الممنوح عند إنشاء المستخدم لموضوع" 17 | post_created_score_value: "قيمة الهتافات الممنوح عند إنشاء المستخدم لمنشور" 18 | flag_created_score_value: "قيمة الهتافات الممنوح عند وضع المستخدم علامة على منشور، ويتم قبول هذه العلامة من قِبل مستخدم في فريق العمل" 19 | day_visited_score_value: "قيمة الهتافات الممنوح لكل يوم يزور فيه المستخدم الموقع" 20 | scorable_categories: "قائمة الفئات التي ستُنشئ فيها الإجراءات هتافات. اتركه فارغًا لتفعيل الهتافات في جميع الفئات" 21 | reaction_received_score_value: "قيمة الهتاف الممنوحة عند تلقي المستخدم تفاعلًا" 22 | reaction_given_score_value: "قيمة الهتاف الممنوحة لكل تفاعل يمنحه المستخدم" 23 | chat_reaction_received_score_value: "قيمة الهتاف الممنوحة عند تلقي المستخدم تفاعلًا على رسالة دردشة" 24 | chat_reaction_given_score_value: "قيمة الهتاف الممنوحة لكل تفاعل يمنحه المستخدم على رسالة دردشة" 25 | chat_message_created_score_value: "قيمة الهتاف الممنوحة لكل رسالة يرسلها مستخدم في دردشة" 26 | score_ranking_strategy: "استراتيجية ترتيب مراكز لوحة المتصدرين" 27 | score: "الهتافات" 28 | default_leaderboard_name: "لوحة المتصدرين العالمية" 29 | rate_limiter: 30 | by_type: 31 | recalculate_scores_remaining: "لقد وصلت إلى الحد الأقصى لإعادة حساب النقاط. يُرجى الانتظار %{time_left} قبل إعادة المحاولة." 32 | errors: 33 | leaderboard_positions_not_ready: "نحن نعمل على إنشاء لوحة المتصدرين الخاصة بك. جرِّب مجددًا بعد بضع دقائق." 34 | -------------------------------------------------------------------------------- /config/locales/server.en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | site_settings: 3 | discourse_gamification_enabled: "Enable the Discourse Gamification Plugin" 4 | like_received_score_value: "The value of the cheer awarded when a user receives a like" 5 | like_given_score_value: "The value of the cheer awarded for every like a user gives" 6 | solution_score_value: "The value of the cheer awarded when a user's post is marked as a solution" 7 | user_invited_score_value: "The value of the cheer awarded when a user has an invite redeemed" 8 | time_read_score_value: "The value of the cheer awarded for every hour of time spent reading" 9 | post_read_score_value: "The value of the cheer awarded for every one hundred posts a user reads" 10 | topic_created_score_value: "The value of the cheer awarded when a user creates a topic" 11 | post_created_score_value: "The value of the cheer awarded when a user creates a post" 12 | flag_created_score_value: "The value of the cheer awarded when a user flags a post and that flag is accepted by a staff user" 13 | day_visited_score_value: "The value of the cheer awarded for every day a user visits the site" 14 | scorable_categories: "List of categories where actions will generate cheers. Leave empty to enable cheers on all categories" 15 | reaction_received_score_value: "The value of the cheer awarded when a user receives a reaction" 16 | reaction_given_score_value: "The value of the cheer awarded for every reaction a user gives" 17 | chat_reaction_received_score_value: "The value of the cheer awarded when a user receives a reaction to a chat message" 18 | chat_reaction_given_score_value: "The value of the cheer awarded for every reaction a user gives to a chat message" 19 | chat_message_created_score_value: "The value of the cheer awarded for every message a user sends in a chat" 20 | score_ranking_strategy: "Leaderboard position ranking strategy" 21 | score: "Cheers" 22 | default_leaderboard_name: "Global Leaderboard" 23 | rate_limiter: 24 | by_type: 25 | recalculate_scores_remaining: "You’ve reached the maximum number of recalculating scores. Please wait %{time_left} before trying again." 26 | errors: 27 | leaderboard_positions_not_ready: "We are generating your leaderboard. Try again in a few minutes." 28 | -------------------------------------------------------------------------------- /config/locales/server.ru.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ru: 8 | site_settings: 9 | discourse_gamification_enabled: "Включить плагин геймификации" 10 | like_received_score_value: "Баллы, начисляемые за получение симпатии" 11 | like_given_score_value: "Баллы, начисляемые за выражение симпатии" 12 | solution_score_value: "Баллы, начисляемые за сообщение, получившее статус 'Вопрос решён'" 13 | user_invited_score_value: "Баллы, начисляемые за принятые приглашения" 14 | time_read_score_value: "Баллы, начисляемые за каждый час, поведённый за чтением форума" 15 | post_read_score_value: "Баллы, начисляемые за каждые сто сообщений, прочитанных пользователем" 16 | topic_created_score_value: "Баллы, начисляемые за создание темы" 17 | post_created_score_value: "Баллы, начисляемые за создание сообщения" 18 | flag_created_score_value: "Баллы, начисляемые за жалобу, принятую персоналом форума" 19 | day_visited_score_value: "Баллы, начисляемые за каждый день посещения форума" 20 | scorable_categories: "Список разделов, в которых начисляются баллы. Оставьте этот список пустым, если баллы должны начисляться во всех разделах" 21 | reaction_received_score_value: "Баллы, начисляемые за получение реакции" 22 | reaction_given_score_value: "Баллы, начисляемые за каждую поставленную реакцию" 23 | chat_reaction_received_score_value: "Баллы, начисляемые за получение реакции на сообщение в чате" 24 | chat_reaction_given_score_value: "Баллы, начисляемые за каждую поставленную реакцию на сообщение в чате" 25 | chat_message_created_score_value: "Баллы, начисляемые за каждое отправленное в чат сообщение" 26 | score_ranking_strategy: "Стратегия ранжирования позиций в таблице лидеров" 27 | score: "Репутация" 28 | default_leaderboard_name: "Глобальная таблица лидеров" 29 | rate_limiter: 30 | by_type: 31 | recalculate_scores_remaining: "Вы пересчитали баллы максимальное количество раз. Повторить попытку можно будет через %{time_left}." 32 | errors: 33 | leaderboard_positions_not_ready: "Мы создаем вашу таблицу лидеров. Повторите попытку через несколько минут." 34 | -------------------------------------------------------------------------------- /spec/plugin_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe ::DiscourseGamification do 6 | let(:user) { Fabricate(:user) } 7 | let!(:gamification_score) { Fabricate(:gamification_score, user_id: user.id) } 8 | 9 | it "adds gamification_score to the UserCardSerializer" do 10 | serializer = UserCardSerializer.new(user) 11 | expect(serializer).to respond_to(:gamification_score) 12 | expect(serializer.gamification_score).to eq(gamification_score.score) 13 | end 14 | 15 | context "with leaderboard positions" do 16 | before { SiteSetting.discourse_gamification_enabled = true } 17 | 18 | it "enqueues job to regenerate leaderboard positions for score ranking strategy changes" do 19 | expect do SiteSetting.score_ranking_strategy = "row_number" end.to change { 20 | Jobs::RegenerateLeaderboardPositions.jobs.size 21 | }.by(1) 22 | end 23 | end 24 | end 25 | 26 | describe ::DiscourseGamification do 27 | let(:guardian) { Guardian.new } 28 | let!(:default_gamification_leaderboard) { Fabricate(:gamification_leaderboard) } 29 | 30 | it "adds default_gamification_leaderboard_id to the SiteSettingSerializer" do 31 | site = Site.new(guardian) 32 | serializer = SiteSerializer.new(site) 33 | expect(serializer).to respond_to(:default_gamification_leaderboard_id) 34 | expect(serializer.default_gamification_leaderboard_id).to eq( 35 | default_gamification_leaderboard.id, 36 | ) 37 | end 38 | end 39 | 40 | context "when merging users" do 41 | fab!(:user_1) { Fabricate(:user) } 42 | fab!(:user_2) { Fabricate(:user) } 43 | fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } 44 | 45 | before do 46 | SiteSetting.discourse_gamification_enabled = true 47 | DiscourseGamification::LeaderboardCachedView.create_all 48 | Fabricate.times(1, :topic, user: user_1) 49 | Fabricate.times(1, :topic, user: user_2) 50 | DiscourseGamification::GamificationScore.calculate_scores 51 | DiscourseGamification::LeaderboardCachedView.refresh_all 52 | end 53 | 54 | it "sums the scores" do 55 | expect(user_2.gamification_score).to eq(5) 56 | 57 | UserMerger.new(user_1, user_2, Discourse.system_user).merge! 58 | DiscourseGamification::LeaderboardCachedView.refresh_all 59 | 60 | expect(user_2.gamification_score).to eq(10) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /config/locales/server.cs.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | cs: 8 | site_settings: 9 | discourse_gamification_enabled: "Povolit plugin Discourse Gamification" 10 | like_received_score_value: "Hodnota ovace udělené, když uživatel obdrží Líbí se" 11 | like_given_score_value: "Hodnota ovace za každé Líbí se, které uživatel udělí" 12 | solution_score_value: "Hodnota ovace udělené, když je příspěvek uživatele označen jako řešení" 13 | user_invited_score_value: "Hodnota ovace, která se uděluje, když je uplatněna pozvánka uživatele" 14 | time_read_score_value: "Hodnota ovace za každou hodinu strávenou čtením" 15 | post_read_score_value: "Hodnota ovace za každých sto příspěvků, které si uživatel přečte" 16 | topic_created_score_value: "Hodnota ovace udělené, když uživatel vytvoří téma" 17 | post_created_score_value: "Hodnota ovace udělené, když uživatel vytvoří příspěvek" 18 | flag_created_score_value: "Hodnota ovace, která se uděluje, když uživatel nahlásí příspěvek, a tento příznak je přijat členem redakce" 19 | day_visited_score_value: "Hodnota ovace za každý den, kdy uživatel navštíví web" 20 | scorable_categories: "Seznam kategorií, kde aktivita bude generovat ovace. Ponechte prázdné, abyste povolili ovace ve všech kategoriích" 21 | reaction_received_score_value: "Hodnota ovace udělené, když uživatel obdrží reakci" 22 | reaction_given_score_value: "Hodnota ovace udělená za každou reakci uživatele" 23 | chat_reaction_received_score_value: "Hodnota ovace udělené, když uživatel obdrží reakci na zprávu chatu" 24 | chat_reaction_given_score_value: "Hodnota ovace udělené za každou reakci uživatele na zprávu chatu" 25 | chat_message_created_score_value: "Hodnota ovace udělená za každou zprávu, kterou uživatel odešle v chatu" 26 | score_ranking_strategy: "Strategie hodnocení pozice v žebříčku" 27 | score: "Ovace" 28 | default_leaderboard_name: "Globální žebříček" 29 | rate_limiter: 30 | by_type: 31 | recalculate_scores_remaining: "Dosáhli jste maximálního počtu prepočítání skóre. Před dalším pokusem počkejte %{time_left}." 32 | errors: 33 | leaderboard_positions_not_ready: "Právě vytváříme váš žebříček. Zkuste to znovu za pár minut." 34 | -------------------------------------------------------------------------------- /app/models/discourse_gamification/gamification_leaderboard.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseGamification 4 | class GamificationLeaderboard < ::ActiveRecord::Base 5 | PAGE_SIZE = 100 6 | 7 | self.table_name = "gamification_leaderboards" 8 | 9 | validates :name, exclusion: { in: %w[new], message: "%{value} is reserved." } 10 | 11 | attribute :period, :integer 12 | enum :period, { all_time: 0, yearly: 1, quarterly: 2, monthly: 3, weekly: 4, daily: 5 } 13 | 14 | def resolve_period(given_period) 15 | return given_period if self.class.periods.key?(given_period) 16 | 17 | self.class.periods.key(default_period) || "all_time" 18 | end 19 | 20 | def self.find_position_by(leaderboard_id:, for_user_id:, period: nil) 21 | self.scores_for(leaderboard_id, for_user_id: for_user_id, period: period).first 22 | end 23 | 24 | def self.scores_for(leaderboard_id, page: 0, for_user_id: false, period: nil, user_limit: nil) 25 | offset = PAGE_SIZE * page 26 | limit = user_limit || PAGE_SIZE 27 | period = period || "all_time" 28 | 29 | leaderboard = self.find(leaderboard_id) 30 | 31 | return [] unless leaderboard 32 | 33 | LeaderboardCachedView.new(leaderboard).scores( 34 | page: page, 35 | for_user_id: for_user_id, 36 | period: period, 37 | limit: limit, 38 | offset: offset, 39 | ) 40 | end 41 | end 42 | end 43 | 44 | # == Schema Information 45 | # 46 | # Table name: gamification_leaderboards 47 | # 48 | # id :bigint not null, primary key 49 | # name :string not null 50 | # from_date :date 51 | # to_date :date 52 | # for_category_id :integer 53 | # created_by_id :integer not null 54 | # created_at :datetime not null 55 | # updated_at :datetime not null 56 | # visible_to_groups_ids :integer default([]), not null, is an Array 57 | # included_groups_ids :integer default([]), not null, is an Array 58 | # excluded_groups_ids :integer default([]), not null, is an Array 59 | # default_period :integer default(0) 60 | # period_filter_disabled :boolean default(FALSE), not null 61 | # 62 | # Indexes 63 | # 64 | # index_gamification_leaderboards_on_name (name) UNIQUE 65 | # 66 | -------------------------------------------------------------------------------- /config/locales/server.es.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | es: 8 | site_settings: 9 | discourse_gamification_enabled: "Activar el plugin Discourse Gamification" 10 | like_received_score_value: "Puntos otorgados al autor de una publicación que reciba un «me gusta»" 11 | like_given_score_value: "Puntos otorgados por cada me gusta que un usuario da" 12 | solution_score_value: "Puntos otorgados cuando la publicación de un usuario es marcada como la solución" 13 | user_invited_score_value: "Puntos otorgados a la persona cuya invitación haya sido usada para crear una nueva cuenta" 14 | time_read_score_value: "Puntos otorgados por cada hora leyendo temas" 15 | post_read_score_value: "Puntos otorgados por cada 100 publicaciones leídas" 16 | topic_created_score_value: "Puntos otorgados por cada tema creado" 17 | post_created_score_value: "Puntos otorgados por cada publicación creada" 18 | flag_created_score_value: "Puntos otorgados a los usuarios por cada uno de sus reportes aceptados por el personal" 19 | day_visited_score_value: "Puntos otorgados por cada día que un usuario visita el sitio" 20 | scorable_categories: "Lista de categorías en las que repartir puntos. Dejar vacío para dar puntos en todas las categorías" 21 | reaction_received_score_value: "Puntos otorgados al autor de una publicación que reciba una reacción" 22 | reaction_given_score_value: "Puntos otorgados por cada reacción que un usuario da" 23 | chat_reaction_received_score_value: "Puntos otorgados al autor de una publicación que reciba una reacción a un mensaje de chat" 24 | chat_reaction_given_score_value: "Puntos otorgados por cada reacción que un usuario da a un mensaje de chat" 25 | chat_message_created_score_value: "Puntos otorgados por cada mensaje que un usuario envía en un chat" 26 | score_ranking_strategy: "Estrategia de clasificación por posiciones" 27 | score: "Puntos" 28 | default_leaderboard_name: "Clasificación global" 29 | rate_limiter: 30 | by_type: 31 | recalculate_scores_remaining: "Ha alcanzado el número máximo de puntuaciones recalculables. Espere %{time_left} antes de intentarlo de nuevo." 32 | errors: 33 | leaderboard_positions_not_ready: "Estamos generando tu tabla de clasificación. Inténtalo de nuevo en unos minutos." 34 | -------------------------------------------------------------------------------- /config/locales/server.fi.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | fi: 8 | site_settings: 9 | discourse_gamification_enabled: "Ota Discourse Gamification -lisäosa käyttöön" 10 | like_received_score_value: "Annetun hurrauksen arvo, kun käyttäjä saa tykkäyksen" 11 | like_given_score_value: "Annetun hurrauksen arvo jokaisesta käyttäjän antamasta tykkäyksestä" 12 | solution_score_value: "Annetun hurrauksen arvo, kun käyttäjän viesti merkitään ratkaisuksi" 13 | user_invited_score_value: "Annetun hurrauksen arvo, kun käyttäjän kutsu hyväksytään" 14 | time_read_score_value: "Annetun hurrauksen arvo jokaisesta lukemiseen käytetystä tunnista" 15 | post_read_score_value: "Annetun hurrauksen arvo jokaisesta sadasta viestistä, jotka käyttäjä lukee" 16 | topic_created_score_value: "Annetun hurrauksen arvo, kun käyttäjä luo ketjun" 17 | post_created_score_value: "Annetun hurrauksen arvo, kun käyttäjä luo viestin" 18 | flag_created_score_value: "Annetun hurrauksen arvo, kun käyttäjä liputtaa viestin, ja henkilökunnan käyttäjä hyväksyy lipun" 19 | day_visited_score_value: "Annetun hurrauksen arvo jokaisesta päivästä, jona käyttäjä vierailee sivustolla" 20 | scorable_categories: "Luettelo alueista, joilla toiminta luo hurrauksia. Hurraukset ovat käytössä kaikilla alueilla, jos jätät tämän tyhjäksi." 21 | reaction_received_score_value: "Annetun hurrauksen arvo, kun käyttäjä saa reaktion" 22 | reaction_given_score_value: "Annetun hurrauksen arvo jokaisesta käyttäjän antamasta reaktiosta" 23 | chat_reaction_received_score_value: "Annetun hurrauksen arvo, kun käyttäjä saa reaktion chat-viestiin" 24 | chat_reaction_given_score_value: "Annetun hurrauksen arvo jokaisesta käyttäjän antamasta reaktiosta chat-viestiin" 25 | chat_message_created_score_value: "Annetun hurrauksen arvo jokaisesta viestistä, jonka käyttäjä lähettää chatissa" 26 | score_ranking_strategy: "Tulostaulukon sijoitusstrategia" 27 | score: "Hurraukset" 28 | default_leaderboard_name: "Yleinen tulostaulukko" 29 | rate_limiter: 30 | by_type: 31 | recalculate_scores_remaining: "Olet saavuttanut pisteiden uudelleenlaskennan enimmäismäärän. Odota %{time_left} ennen kuin yrität uudelleen." 32 | errors: 33 | leaderboard_positions_not_ready: "Luomme tulostaulukkoasi. Yritä uudelleen muutaman minuutin kuluttua." 34 | -------------------------------------------------------------------------------- /config/locales/server.hu.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hu: 8 | site_settings: 9 | discourse_gamification_enabled: "Engedélyezze a Discourse Gamifikáció beépülő modult" 10 | like_received_score_value: "Pontszám értéke, amelyet akkor kap a felhasználó, ha egy like-ot kap." 11 | like_given_score_value: "Pontszám értéke, amelyet a felhasználó minden egyes like-ért kap." 12 | solution_score_value: "Pontszám értéke, amikor egy felhasználó bejegyzését megoldásnak jelölik meg" 13 | user_invited_score_value: "Pontszám értéke, amelyet a felhasználó után beváltott meghívókért kap" 14 | time_read_score_value: "Pontszám értéke, amelyet minden eltert olvasott óráért jár" 15 | post_read_score_value: "Pontszám értéke, amelyet minden századik elolvasott bejegyzés után jár." 16 | topic_created_score_value: "Pontszám értéke, amely új téma létrehozásáért jár" 17 | post_created_score_value: "Pontszám értéke, amely új bejegyzés létrehozásáért jár" 18 | flag_created_score_value: "Pontszám értéke, ami minden olyan megjelölés után jár, amit egy stábtag elfogadott" 19 | day_visited_score_value: "Pontszám értéke, amely minden napi első bejelentkezés után jár" 20 | scorable_categories: "Azoknak a kategóriáknak a listája, ahol a cselekvésekért pontszámok járnak. Hagyja üresen, hogy az összes kategóriát engedélyezze" 21 | reaction_received_score_value: "Pontszám értéke, amelyet akkor kapnak, amikor a felhasználó reakciót kap" 22 | reaction_given_score_value: "Pontszám értéke, amelyet akkor kapnak, amikor a felhasználó reakciót ad" 23 | chat_reaction_received_score_value: "Pontszám értéke, amelyet akkor kap a felhasználó, ha reagál egy chat-üzenetre." 24 | chat_reaction_given_score_value: "A csevegőüzenetre adott felhasználói reakciókért járó pontszám" 25 | chat_message_created_score_value: "A csevegés során a felhasználó által elküldött minden üzenetért járó pontszám" 26 | score_ranking_strategy: "Ranglista pozíció rangsorolási stratégia" 27 | score: "Pontok" 28 | default_leaderboard_name: "Globális ranglista" 29 | rate_limiter: 30 | by_type: 31 | recalculate_scores_remaining: "Elérte az újraszámítási pontszámok maximális számát. Kérjük, várjon %{time_left} , mielőtt újra próbálkozna." 32 | errors: 33 | leaderboard_positions_not_ready: "Létrehozzuk a ranglistát. Próbálja újra néhány perc múlva." 34 | -------------------------------------------------------------------------------- /app/models/discourse_gamification/gamification_score.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseGamification 4 | class GamificationScore < ::ActiveRecord::Base 5 | self.table_name = "gamification_scores" 6 | 7 | belongs_to :user 8 | 9 | def self.enabled_scorables 10 | Scorable.subclasses.filter { _1.enabled? } 11 | end 12 | 13 | def self.scorables_queries 14 | enabled_scorables.map { "( #{_1.query} )" }.join(" UNION ALL ") 15 | end 16 | 17 | def self.calculate_scores(since_date: Date.today, only_subclass: nil) 18 | queries = only_subclass&.query || scorables_queries 19 | 20 | DB.exec(<<~SQL, since: since_date) 21 | DELETE FROM gamification_scores 22 | WHERE date >= :since; 23 | 24 | INSERT INTO gamification_scores (user_id, date, score) 25 | SELECT user_id, date, SUM(points) AS score 26 | FROM ( 27 | #{queries} 28 | UNION ALL 29 | SELECT user_id, date, SUM(points) AS points 30 | FROM gamification_score_events 31 | WHERE date >= :since 32 | GROUP BY 1, 2 33 | ) AS source 34 | WHERE user_id IS NOT NULL 35 | GROUP BY 1, 2 36 | ON CONFLICT (user_id, date) DO UPDATE 37 | SET score = EXCLUDED.score; 38 | SQL 39 | end 40 | 41 | def self.merge_scores(source_user, target_user) 42 | DB.exec(<<~SQL, source_id: source_user.id, target_id: target_user.id) 43 | WITH new_scores AS ( 44 | SELECT :target_id AS user_id, date, SUM(score) AS score 45 | FROM gamification_scores 46 | WHERE user_id IN (:source_id, :target_id) 47 | GROUP BY 1, 2 48 | ) INSERT INTO gamification_scores (user_id, date, score) 49 | SELECT user_id, date, score AS score 50 | FROM new_scores 51 | ON CONFLICT (user_id, date) DO UPDATE 52 | SET score = EXCLUDED.score; 53 | SQL 54 | 55 | DB.exec(<<~SQL, source_id: source_user.id) 56 | DELETE FROM gamification_scores 57 | WHERE user_id = :source_id; 58 | SQL 59 | end 60 | end 61 | end 62 | 63 | # == Schema Information 64 | # 65 | # Table name: gamification_scores 66 | # 67 | # id :bigint not null, primary key 68 | # user_id :integer not null 69 | # date :date not null 70 | # score :integer not null 71 | # 72 | # Indexes 73 | # 74 | # index_gamification_scores_on_date (date) 75 | # index_gamification_scores_on_user_id_and_date (user_id,date) UNIQUE 76 | # 77 | -------------------------------------------------------------------------------- /config/locales/client.sv.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sv: 8 | js: 9 | gamification_score: "Hurra" 10 | gamification: 11 | score: "Hurra" 12 | you: "Du" 13 | leaderboard: 14 | title: "Topplistor" 15 | info: "Hur fungerar detta?" 16 | link_to_settings: "Inställningar" 17 | refresh: "Uppdatera" 18 | modal: 19 | title: "Hur fungerar topplistan?" 20 | text: "Poäng delas ut för att du engagerar dig i gemenskapen, till exempel genom att besöka, gilla och publicera. Dina poäng uppdateras med några minuters mellanrum. Var hjälpsam, aktiv och stödjande och stig sedan i graderna!" 21 | name: "Namn" 22 | name_placeholder: "Namn..." 23 | cta: "Skapa din första topplista" 24 | none: "Inga topplistor har skapats ännu." 25 | confirm_destroy: "Är du säker på att du vill ta bort denna topplista?" 26 | date: 27 | range: "Från / Till datumintervall" 28 | helper: "Om datum lämnas tomma kommer topplistan att visa intjänade poäng utan tidsbegränsningar." 29 | visible_to_groups: "Synlig för grupper" 30 | visible_to_groups_help: "Endast användare i dessa grupper kommer att kunna se topplistan. Lämna tomt för att tillåta alla." 31 | included_groups: "Inkluderade grupper" 32 | included_groups_help: "Endast användare i dessa grupper kommer att inkluderas i topplistan. Lämna tomt för att lista alla." 33 | excluded_groups: "Exkluderade grupper" 34 | excluded_groups_help: "Ta bort användare i dessa grupper från att inkluderas i topplistan. Lämna tomt för att lista alla." 35 | period: 36 | all_time: "Alltid" 37 | yearly: "Årsvis" 38 | quarterly: "Kvartalsvis" 39 | monthly: "Månadsvis" 40 | weekly: "Veckovis" 41 | daily: "Dagligen" 42 | create: "Skapa" 43 | cancel: "Avbryt" 44 | close: "Stäng" 45 | delete: "Radera" 46 | edit: "Redigera" 47 | back: "Tillbaka" 48 | save: "Spara" 49 | apply: "Tillämpa" 50 | update_range: 51 | last_10_days: "Senaste 10 dagarna" 52 | last_30_days: "Senaste 30 dagarna" 53 | last_90_days: "Senaste 90 dagarna" 54 | all_time: "Alltid" 55 | custom_range_from: "Från" 56 | admin: 57 | title: "Gamification" 58 | name: "Namn" 59 | period: "Period" 60 | -------------------------------------------------------------------------------- /config/locales/server.tr_TR.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | tr_TR: 8 | site_settings: 9 | discourse_gamification_enabled: "Discourse Oyunlaştırma Eklentisini Etkinleştirin" 10 | like_received_score_value: "Bir kullanıcı bir beğeni aldığında verilen tezahüratın değeri" 11 | like_given_score_value: "Bir kullanıcının verdiği her beğeni için verilen tezahüratın değeri" 12 | solution_score_value: "Bir kullanıcının gönderisi çözüm olarak işaretlendiğinde verilen tezahüratın değeri" 13 | user_invited_score_value: "Bir kullanıcı bir davetiyeyi kullandığında verilen tezahüratın değeri" 14 | time_read_score_value: "Okumak için harcanan her saat için verilen tezahüratın değeri" 15 | post_read_score_value: "Bir kullanıcının okuduğu her yüz gönderi için verilen tezahüratın değeri" 16 | topic_created_score_value: "Bir kullanıcı bir konu oluşturduğunda verilen tezahüratın değeri" 17 | post_created_score_value: "Bir kullanıcı bir gönderi oluşturduğunda verilen tezahüratın değeri" 18 | flag_created_score_value: "Bir kullanıcı bir gönderiye bayrak eklendiğinde ve bu bayrak bir personel kullanıcı tarafından kabul edildiğinde verilen tezahüratın değeri" 19 | day_visited_score_value: "Bir kullanıcının siteyi ziyaret ettiği her gün için verilen tezahüratın değeri" 20 | scorable_categories: "Eylemlerin tezahürat oluşturacağı kategorilerin listesi. Tüm kategorilerde tezahüratları etkinleştirmek için boş bırakın" 21 | reaction_received_score_value: "Bir kullanıcı tepki aldığında verilen tezahüratın değeri" 22 | reaction_given_score_value: "Bir kullanıcının verdiği her tepki için verilen tezahüratın değeri" 23 | chat_reaction_received_score_value: "Bir kullanıcı bir sohbet mesajına tepki aldığında verilen tezahüratın değeri" 24 | chat_reaction_given_score_value: "Bir kullanıcının bir sohbet mesajına verdiği her tepki için verilen tezahüratın değeri" 25 | chat_message_created_score_value: "Bir kullanıcının sohbette gönderdiği her mesaj için verilen tezahüratın değeri" 26 | score_ranking_strategy: "Liderlik tablosu pozisyon sıralama stratejisi" 27 | score: "Tezahürat" 28 | default_leaderboard_name: "Küresel Liderlik Tablosu" 29 | rate_limiter: 30 | by_type: 31 | recalculate_scores_remaining: "Maksimum puan yeniden hesaplama sayısına ulaştınız. Lütfen tekrar denemeden önce %{time_left} bekleyin." 32 | errors: 33 | leaderboard_positions_not_ready: "Liderlik tablonuzu oluşturuyoruz. Birkaç dakika içinde tekrar deneyin." 34 | -------------------------------------------------------------------------------- /config/locales/client.zh_CN.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | zh_CN: 8 | admin_js: 9 | admin: 10 | site_settings: 11 | categories: 12 | discourse_gamification: "Discourse Gamification" 13 | js: 14 | gamification_score: "点数" 15 | gamification: 16 | score: "点数" 17 | you: "您" 18 | leaderboard: 19 | title: "排行榜" 20 | info: "排行榜是如何运作的?" 21 | link_to_settings: "设置" 22 | refresh: "刷新" 23 | modal: 24 | title: "排行榜是如何运作的?" 25 | text: "参与社区活动,如访问、点赞和发帖,都会获得积分。您的积分每几分钟就会更新一次。保持活跃,积极帮助并支持其他人来提高自己的排名!" 26 | name: "名称" 27 | name_placeholder: "名称…" 28 | new: "新排行榜" 29 | create_success: "排行榜已创建" 30 | delete_success: "排行榜已删除" 31 | save_success: "排行榜已保存" 32 | cta: "制作您的第一个排行榜" 33 | none: "尚未创建排行榜。" 34 | confirm_destroy: "确定要删除此排行榜吗?" 35 | date: 36 | range: "开始/结束日期范围" 37 | from: "起始日期" 38 | to: "截止日期" 39 | helper: "如果日期留空,排行榜将显示全部时间内获得的分数。" 40 | visible_to_groups: "对以下群组可见" 41 | visible_to_groups_help: "只有这些群组中的用户才能查看排行榜。留空以允许所有用户查看。" 42 | included_groups: "包含的群组" 43 | included_groups_help: "只有这些群组中的用户才会被包含到排行榜中。留空以让所有人都参与排行榜。" 44 | excluded_groups: "排除的群组" 45 | excluded_groups_help: "将这些群组中的用户从排行榜中移除。留空以让所有人都参与排行榜。" 46 | default_period: "默认时间段" 47 | default_period_help: "设置要为此排行榜显示的默认时间段。" 48 | period_filter_disabled: "禁用时间段筛选器" 49 | period: 50 | all_time: "所有时间" 51 | yearly: "每年" 52 | quarterly: "每季度" 53 | monthly: "每月" 54 | weekly: "每周" 55 | daily: "每天" 56 | rank: "排名" 57 | create: "创建" 58 | cancel: "取消" 59 | close: "关闭" 60 | delete: "删除" 61 | edit: "编辑" 62 | back: "返回" 63 | save: "保存" 64 | apply: "应用" 65 | recalculate: "重新计算分数" 66 | recalculating: "正在重新计算分数…" 67 | completed: "完成!分数已成功重新计算。" 68 | update_scores_help: "更新以下时间范围内所有排行榜的所有分数:" 69 | update_range: 70 | last_10_days: "过去 10 天" 71 | last_30_days: "过去 30 天" 72 | last_90_days: "过去 90 天" 73 | last_year: "去年" 74 | all_time: "所有时间" 75 | custom_date_range: "自定义范围" 76 | custom_range_from: "从" 77 | daily_update_scores_availability: 78 | other: "剩余 %{count} 次每日重新计算" 79 | admin: 80 | title: "游戏化" 81 | name: "名称" 82 | period: "时间段" 83 | -------------------------------------------------------------------------------- /config/locales/server.pt_BR.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | pt_BR: 8 | site_settings: 9 | discourse_gamification_enabled: "Habilite o Plugin de Gamificação do Discourse" 10 | like_received_score_value: "O valor da saudação concedida quando um usuário recebe uma curtida" 11 | like_given_score_value: "O valor da saudação concedida por cada curtida feita por um usuário" 12 | solution_score_value: "O valor da saudação concedida quando a postagem de um usuário é marcada como solução" 13 | user_invited_score_value: "O valor da saudação concedida quando um usuário tiver um convite resgatado" 14 | time_read_score_value: "O valor da saudação concedida por cada hora de tempo gasto na leitura" 15 | post_read_score_value: "O valor da saudação concedida para cada cem postagens que um usuário lê" 16 | topic_created_score_value: "O valor da saudação concedida quando um usuário cria um tópico" 17 | post_created_score_value: "O valor da saudação concedida quando um usuário cria uma postagem" 18 | flag_created_score_value: "O valor da saudação concedida quando um usuário sinaliza uma postagem e essa sinalização é aceita por um usuário da equipe" 19 | day_visited_score_value: "O valor da saudação concedida para cada dia que um usuário visita o site" 20 | scorable_categories: "Lista de categorias em que as ações vão gerar saudações. Deixe em branco para ativar saudações em todas as categorias" 21 | reaction_received_score_value: "O valor da saudação concedida quando um(a) usuário(a) recebe uma reação" 22 | reaction_given_score_value: "O valor da saudação concedida por cada reação feita por um(a) usuário(a)" 23 | chat_reaction_received_score_value: "O valor da saudação concedida quando um(a) usuário(a) recebe uma reação para uma mensagem de chat" 24 | chat_reaction_given_score_value: "O valor da saudação concedida por cada reação feita por um(a) usuário(a) para uma mensagem de chat" 25 | chat_message_created_score_value: "O valor da saudação concedida por cada mensagem enviada por um(a) usuário(a) no chat" 26 | score_ranking_strategy: "Estratégia de classificação de posição no placar" 27 | score: "Saudações" 28 | default_leaderboard_name: "Tabela de Classificação Global" 29 | rate_limiter: 30 | by_type: 31 | recalculate_scores_remaining: "Você atingiu o número máximo de pontuação recalculada. Espere %{time_left} antes de tentar novamente." 32 | errors: 33 | leaderboard_positions_not_ready: "Estamos gerando seu placar. Tente novamente em alguns minutos." 34 | -------------------------------------------------------------------------------- /config/locales/server.it.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | it: 8 | site_settings: 9 | discourse_gamification_enabled: "Abilita il plugin di Discourse Gamification" 10 | like_received_score_value: "Il valore dei complimenti assegnati quando un utente riceve un Mi piace" 11 | like_given_score_value: "Il valore dei complimenti assegnati per ogni Mi piace messo da un utente" 12 | solution_score_value: "Il valore dei complimenti assegnati quando il messaggio di un utente è contrassegnato come soluzione" 13 | user_invited_score_value: "Il valore dei complimenti assegnati quando un utente ha un invito riscattato" 14 | time_read_score_value: "Il valore dei complimenti assegnati per ogni ora trascorsa leggendo" 15 | post_read_score_value: "Il valore dei complimenti assegnati per ogni cento messaggi letti da un utente" 16 | topic_created_score_value: "Il valore dei complimenti assegnati quando un utente crea un argomento" 17 | post_created_score_value: "Il valore dei complimenti assegnati quando un utente crea un messaggio" 18 | flag_created_score_value: "Il valore dei complimenti assegnati quando un utente segnala un messaggio e la segnalazione è accettata da un utente dello staff" 19 | day_visited_score_value: "Il valore dei complimenti assegnati per ogni giorno in cui un utente visita il sito" 20 | scorable_categories: "Elenco di categorie in cui le azioni genereranno complimenti. Lascia vuota l'opzione per abilitare i complimenti in tutte le categorie" 21 | reaction_received_score_value: "Il valore dei complimenti assegnati quando un utente riceve una reazione" 22 | reaction_given_score_value: "Il valore dei complimenti assegnati per ogni reazione messa da un utente" 23 | chat_reaction_received_score_value: "Il valore dei complimenti assegnati quando un utente riceve una reazione a un messaggio di chat" 24 | chat_reaction_given_score_value: "Il valore dei complimenti assegnati per ogni reazione messa da un utente a un messaggio di chat" 25 | chat_message_created_score_value: "Il valore dei complimenti assegnati per ogni messaggio che un utente invia in chat" 26 | score_ranking_strategy: "Strategia di posizionamento in classifica" 27 | score: "Complimenti" 28 | default_leaderboard_name: "Classifica globale" 29 | rate_limiter: 30 | by_type: 31 | recalculate_scores_remaining: "Hai raggiunto il numero massimo di ricalcoli di punteggio. Attendi %{time_left} prima di riprovare." 32 | errors: 33 | leaderboard_positions_not_ready: "Stiamo generando la tua classifica. Riprova tra qualche minuto." 34 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/minimal-gamification-leaderboard.gjs: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { tracked } from "@glimmer/tracking"; 3 | import { LinkTo } from "@ember/routing"; 4 | import { service } from "@ember/service"; 5 | import icon from "discourse/helpers/d-icon"; 6 | import number from "discourse/helpers/number"; 7 | import { ajax } from "discourse/lib/ajax"; 8 | import { i18n } from "discourse-i18n"; 9 | import fullnumber from "../helpers/fullnumber"; 10 | import MinimalGamificationLeaderboardRow from "./minimal-gamification-leaderboard-row"; 11 | 12 | export default class extends Component { 13 | @service site; 14 | 15 | @tracked model; 16 | 17 | constructor() { 18 | super(...arguments); 19 | 20 | // id is used by discourse-right-sidebar-blocks theme component 21 | const endpoint = this.args.id 22 | ? `/leaderboard/${this.args.id}` 23 | : "/leaderboard"; 24 | 25 | ajax(endpoint, { data: { user_limit: this.args.count || 10 } }).then( 26 | (model) => { 27 | for (const user of model.users) { 28 | if (user.id === model.personal?.user?.id) { 29 | user.isCurrentUser = "true"; 30 | } 31 | } 32 | 33 | if (model.users[0]) { 34 | model.users[0].topRanked = true; 35 | } 36 | 37 | this.model = model; 38 | } 39 | ); 40 | } 41 | 42 | get notTop10() { 43 | return this.model?.personal?.position > 10; 44 | } 45 | 46 | 83 | } 84 | -------------------------------------------------------------------------------- /admin/assets/javascripts/admin/components/admin-create-leaderboard.gjs: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { tracked } from "@glimmer/tracking"; 3 | import { action } from "@ember/object"; 4 | import { readOnly } from "@ember/object/computed"; 5 | import { service } from "@ember/service"; 6 | import Form from "discourse/components/form"; 7 | import { ajax } from "discourse/lib/ajax"; 8 | import { popupAjaxError } from "discourse/lib/ajax-error"; 9 | import { i18n } from "discourse-i18n"; 10 | 11 | export default class AdminCreateLeaderboard extends Component { 12 | @service currentUser; 13 | @service router; 14 | @service toasts; 15 | 16 | @tracked newLeaderboardName = ""; 17 | @tracked loading = false; 18 | 19 | @readOnly("newLeaderboardName") nameValid; 20 | 21 | get formData() { 22 | return { name: "", created_by_id: this.currentUser.id }; 23 | } 24 | 25 | @action 26 | async createNewLeaderboard(data) { 27 | if (this.loading) { 28 | return; 29 | } 30 | 31 | this.loading = true; 32 | 33 | try { 34 | const leaderboard = await ajax( 35 | "/admin/plugins/gamification/leaderboard", 36 | { 37 | data, 38 | type: "POST", 39 | } 40 | ); 41 | this.toasts.success({ 42 | duration: 3000, 43 | data: { 44 | message: i18n("gamification.leaderboard.create_success"), 45 | }, 46 | }); 47 | this.args.onCancel(); 48 | this.router.transitionTo( 49 | "adminPlugins.show.discourse-gamification-leaderboards.show", 50 | leaderboard.id 51 | ); 52 | } catch (err) { 53 | popupAjaxError(err); 54 | } finally { 55 | this.loading = false; 56 | } 57 | } 58 | 59 | 95 | } 96 | -------------------------------------------------------------------------------- /config/locales/server.fr.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | fr: 8 | site_settings: 9 | discourse_gamification_enabled: "Activer l'extension Discourse Gamification" 10 | like_received_score_value: "La valeur de l'acclamation accordée lorsqu'un utilisateur reçoit une mention J'aime" 11 | like_given_score_value: "La valeur de l'acclamation accordée pour chaque mention « J'aime » donnée par un utilisateur" 12 | solution_score_value: "La valeur de l'acclamation accordée lorsque le message d'un utilisateur est marqué comme une solution" 13 | user_invited_score_value: "La valeur de l'acclamation accordée lorsqu'un utilisateur a reçu une invitation" 14 | time_read_score_value: "La valeur de l'acclamation accordée pour chaque heure passée à lire" 15 | post_read_score_value: "La valeur de l'acclamation accordée pour chaque lot de cent messages lus par un utilisateur" 16 | topic_created_score_value: "La valeur de l'acclamation accordée lorsqu'un utilisateur crée un sujet" 17 | post_created_score_value: "La valeur de l'acclamation accordée lorsqu'un utilisateur crée un message" 18 | flag_created_score_value: "La valeur de l'acclamation accordée lorsqu'un utilisateur signale un message et que ce signalement est accepté par un responsable" 19 | day_visited_score_value: "La valeur de l'acclamation accordée pour chaque jour où un utilisateur visite le site" 20 | scorable_categories: "Liste des catégories où les actions susciteront des acclamations. Laissez ce champ vide pour activer les acclamations dans toutes les catégories" 21 | reaction_received_score_value: "La valeur de l'acclamation accordée lorsqu'un utilisateur reçoit une réaction" 22 | reaction_given_score_value: "La valeur de l'acclamation accordée pour chaque réaction donnée par un utilisateur" 23 | chat_reaction_received_score_value: "La valeur de l'acclamation accordée lorsqu'un utilisateur reçoit une réaction à un message de discussion" 24 | chat_reaction_given_score_value: "La valeur de l'acclamation accordée pour chaque réaction donnée par un utilisateur à un message de discussion" 25 | chat_message_created_score_value: "La valeur de l'acclamation accordée pour chaque message qu'un utilisateur envoie dans une discussion" 26 | score_ranking_strategy: "Stratégie de classement de la position au tableau d'affichage" 27 | score: "Acclamations" 28 | default_leaderboard_name: "Classement mondial" 29 | rate_limiter: 30 | by_type: 31 | recalculate_scores_remaining: "Vous avez atteint le nombre maximal de recalculs de scores. Veuillez patienter %{time_left} avant de réessayer." 32 | errors: 33 | leaderboard_positions_not_ready: "Nous générons votre classement. Réessayez dans quelques minutes." 34 | -------------------------------------------------------------------------------- /config/locales/client.ja.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ja: 8 | admin_js: 9 | admin: 10 | site_settings: 11 | categories: 12 | discourse_gamification: "Discourse ゲーミフィケーション" 13 | js: 14 | gamification_score: "拍手" 15 | gamification: 16 | score: "拍手" 17 | you: "あなた" 18 | leaderboard: 19 | title: "リーダーボード" 20 | info: "仕組みは?" 21 | link_to_settings: "設定" 22 | refresh: "更新" 23 | modal: 24 | title: "リーダーボードの仕組みは?" 25 | text: "アクセス、「いいね!」、投稿など、コミュニティーでのアクションに対してポイントが付与されます。スコアは数分ごとに更新されます。活発な協力を通じて、ランクを上げましょう!" 26 | name: "名前" 27 | name_placeholder: "名前..." 28 | new: "新しいリーダーボード" 29 | create_success: "リーダーボードが作成されました" 30 | delete_success: "リーダーボードが削除されました" 31 | save_success: "リーダーボードが保存されました" 32 | cta: "最初のリーダーボードを作成しよう" 33 | none: "リーダーボードはまだ作成されていません。" 34 | confirm_destroy: "このリーダーボードを削除してもよろしいですか?" 35 | date: 36 | range: "開始日/終了日の範囲" 37 | from: "開始日" 38 | to: "終了日" 39 | helper: "日付が空である場合、リーダーボードには期間制限なしで獲得されたスコアが表示されます。" 40 | visible_to_groups: "グループに表示" 41 | visible_to_groups_help: "これらのグループのユーザーのみがリーダーボードを表示できます。全員を許可する場合は、空白のままにします。" 42 | included_groups: "含まれるグループ" 43 | included_groups_help: "これらのグループのユーザーのみがリーダーボードに含まれます。全員を表示する場合は、空白のままにします。" 44 | excluded_groups: "除外されるグループ" 45 | excluded_groups_help: "これらのグループのユーザーは、リーダーボードに含まれません。全員を表示する場合は、空白のままにします。" 46 | default_period: "デフォルトの期間" 47 | default_period_help: "このリーダーボードに表示するデフォルトの期間を設定します。" 48 | period_filter_disabled: "期間フィルタを無効化" 49 | period: 50 | all_time: "全期間" 51 | yearly: "年間" 52 | quarterly: "四半期" 53 | monthly: "月間" 54 | weekly: "週間" 55 | daily: "日間" 56 | rank: "ランク" 57 | create: "作成" 58 | cancel: "キャンセル" 59 | close: "閉じる" 60 | delete: "削除" 61 | edit: "編集" 62 | back: "戻る" 63 | save: "保存" 64 | apply: "適用" 65 | recalculate: "スコアを再計算" 66 | recalculating: "スコアを再計算中..." 67 | completed: "完了!スコアが正常に再計算されました。" 68 | update_scores_help: "次の時間からの全リーダーボードのすべてのスコアを更新します:" 69 | update_range: 70 | last_10_days: "過去 10 日間" 71 | last_30_days: "過去 30 日間" 72 | last_90_days: "過去 90 日間" 73 | last_year: "昨年" 74 | all_time: "全期間" 75 | custom_date_range: "カスタム範囲" 76 | custom_range_from: "開始" 77 | daily_update_scores_availability: 78 | other: "1 日の再計算回数は残り %{count} 回" 79 | admin: 80 | title: "ゲーミフィケーション" 81 | name: "名前" 82 | period: "期間" 83 | -------------------------------------------------------------------------------- /config/locales/server.de.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | de: 8 | site_settings: 9 | discourse_gamification_enabled: "Discourse-Gamifizierung-Plug-in aktivieren" 10 | like_received_score_value: "Der Wert des Beifalls, der vergeben wird, wenn ein Benutzer ein „Gefällt mir“ erhält" 11 | like_given_score_value: "Der Wert des Beifalls, der für jedes „Gefällt mir“ vergeben wird, das ein Benutzer gibt" 12 | solution_score_value: "Der Wert des Beifalls, der vergeben wird, wenn der Beitrag eines Benutzers als Lösung markiert wird" 13 | user_invited_score_value: "Der Wert des Beifalls, der vergeben wird, wenn ein Benutzer eine eingelöste Einladung hat" 14 | time_read_score_value: "Der Wert des Beifalls, der für jede Stunde Lesezeit vergeben wird" 15 | post_read_score_value: "Der Wert des Beifalls, der für jeweils hundert Beiträge vergeben wird, die ein Benutzer liest" 16 | topic_created_score_value: "Der Wert des Beifalls, der vergeben wird, wenn ein Benutzer ein Thema erstellt" 17 | post_created_score_value: "Der Wert des Beifalls, der vergeben wird, wenn ein Benutzer einen Beitrag erstellt" 18 | flag_created_score_value: "Der Wert des Beifalls, der vergeben wird, wenn ein Benutzer einen Beitrag meldet und diese Meldung von einem Team-Benutzer akzeptiert wird" 19 | day_visited_score_value: "Der Wert des Beifalls, der für jeden Tag vergeben wird, an dem ein Benutzer die Website besucht" 20 | scorable_categories: "Liste der Kategorien, in denen Aktionen Beifall erzeugen. Leer lassen, um Beifall für alle Kategorien zu aktivieren" 21 | reaction_received_score_value: "Der Wert des Beifalls, der vergeben wird, wenn ein Benutzer eine Reaktion erhält" 22 | reaction_given_score_value: "Der Wert des Beifalls, der für jede Reaktion vergeben wird, die ein Benutzer gibt" 23 | chat_reaction_received_score_value: "Der Wert des Beifalls, der vergeben wird, wenn ein Benutzer eine Reaktion auf eine Chat-Nachricht erhält" 24 | chat_reaction_given_score_value: "Der Wert des Beifalls, der für jede Reaktion vergeben wird, die ein Benutzer für eine Chat-Nachricht gibt" 25 | chat_message_created_score_value: "Der Wert des Beifalls, der für jede Nachricht vergeben wird, die ein Benutzer in einem Chat sendet" 26 | score_ranking_strategy: "Strategie für die Ranglistenposition" 27 | score: "Beifall" 28 | default_leaderboard_name: "Globale Rangliste" 29 | rate_limiter: 30 | by_type: 31 | recalculate_scores_remaining: "Du hast die maximale Anzahl an Score-Neuberechnungen erreicht. Bitte warte %{time_left}, bevor du es wieder versuchst." 32 | errors: 33 | leaderboard_positions_not_ready: "Wir generieren gerade deine Rangliste. Versuche es in ein paar Minuten noch einmal." 34 | -------------------------------------------------------------------------------- /spec/lib/directory_integration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe DiscourseGamification::DirectoryIntegration do 6 | fab!(:user_1) { Fabricate(:admin) } 7 | fab!(:user_2) { Fabricate(:user) } 8 | fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } 9 | fab!(:score_1) { Fabricate(:gamification_score, user_id: user_1.id, score: 10, date: 8.days.ago) } 10 | fab!(:score_2) { Fabricate(:gamification_score, user_id: user_1.id, score: 40, date: 3.days.ago) } 11 | fab!(:score_3) { Fabricate(:gamification_score, user_id: user_2.id, score: 25, date: 5.days.ago) } 12 | fab!(:score_4) { Fabricate(:gamification_score, user_id: user_2.id, score: 5, date: 2.days.ago) } 13 | 14 | before do 15 | SiteSetting.discourse_gamification_enabled = true 16 | DirectoryItem.refresh! 17 | end 18 | 19 | def all_time_score_for(user) 20 | user.directory_items.find_by(period_type: 1).gamification_score 21 | end 22 | 23 | context "with a date-restricted default leaderboard" do 24 | context "with only a 'from_date'" do 25 | before do 26 | leaderboard.update(from_date: 5.days.ago.to_date) 27 | DirectoryItem.refresh! 28 | end 29 | 30 | it "returns sum of points earned from leaderboard's 'from_date'" do 31 | expect(all_time_score_for(user_1)).to eq(40) 32 | expect(all_time_score_for(user_2)).to eq(30) 33 | end 34 | end 35 | 36 | context "with only a 'to_date'" do 37 | before do 38 | leaderboard.update(to_date: 4.days.ago.to_date) 39 | DirectoryItem.refresh! 40 | end 41 | 42 | it "returns sum of points earned upto leaderboard's 'to_date'" do 43 | expect(all_time_score_for(user_1)).to eq(10) 44 | expect(all_time_score_for(user_2)).to eq(25) 45 | end 46 | end 47 | 48 | context "with both 'from_date' and 'to_date'" do 49 | before do 50 | leaderboard.update(from_date: 5.days.ago.to_date, to_date: 3.days.ago.to_date) 51 | DirectoryItem.refresh! 52 | end 53 | 54 | it "returns sum of points earned between leaderboard's 'from_date' and 'to_date'" do 55 | expect(DiscourseGamification::GamificationScore.where(user: user_1).sum(:score)).to eq(50) 56 | expect(DiscourseGamification::GamificationScore.where(user: user_2).sum(:score)).to eq(30) 57 | 58 | expect(all_time_score_for(user_1)).to eq(40) 59 | expect(all_time_score_for(user_2)).to eq(25) 60 | end 61 | end 62 | end 63 | 64 | context "without a date-restricted default leaderboard" do 65 | it "returns sum of all scores for the period" do 66 | expect(DiscourseGamification::GamificationScore.where(user: user_1).sum(:score)).to eq(50) 67 | expect(DiscourseGamification::GamificationScore.where(user: user_2).sum(:score)).to eq(30) 68 | 69 | expect(all_time_score_for(user_1)).to eq(50) 70 | expect(all_time_score_for(user_2)).to eq(30) 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/system/recalculate_scores_form_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe "Recalculate Scores Form", type: :system do 4 | let(:recalculate_scores_modal) { PageObjects::Modals::RecalculateScoresForm.new } 5 | 6 | fab!(:admin) 7 | fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } 8 | 9 | before do 10 | RateLimiter.enable 11 | SiteSetting.discourse_gamification_enabled = true 12 | DiscourseGamification::LeaderboardCachedView.new(leaderboard).create 13 | sign_in(admin) 14 | end 15 | 16 | def format_date(date) 17 | date.midnight.strftime("%b %-d, %Y") 18 | end 19 | 20 | it "has date options that are valid and can be applied" do 21 | freeze_time 22 | 23 | visit("/admin/plugins/gamification") 24 | find(".leaderboard-admin__btn-recalculate").click 25 | 26 | today = format_date(Time.now) 27 | 28 | recalculate_scores_modal.select_update_range(value: 0) 29 | expect(recalculate_scores_modal.date_range.text).to eq("#{format_date(10.days.ago)} - #{today}") 30 | 31 | recalculate_scores_modal.select_update_range(value: 1) 32 | expect(recalculate_scores_modal.date_range.text).to eq("#{format_date(30.days.ago)} - #{today}") 33 | 34 | recalculate_scores_modal.select_update_range(value: 2) 35 | expect(recalculate_scores_modal.date_range.text).to eq("#{format_date(90.days.ago)} - #{today}") 36 | 37 | recalculate_scores_modal.select_update_range(value: 3) 38 | expect(recalculate_scores_modal.date_range.text).to eq("#{format_date(1.year.ago)} - #{today}") 39 | 40 | recalculate_scores_modal.select_update_range(value: 4) 41 | expect(recalculate_scores_modal.date_range.text).to eq("") 42 | 43 | recalculate_scores_modal.select_update_range(value: 5) 44 | expect(recalculate_scores_modal.custom_since_date).to be_visible 45 | 46 | recalculate_scores_modal.fill_since_date(today) 47 | expect(recalculate_scores_modal.custom_since_date.value).to eq(today) 48 | end 49 | 50 | context "when admin has daily recalculation remaining" do 51 | it "can trigger recalculation" do 52 | visit("/admin/plugins/gamification") 53 | find(".leaderboard-admin__btn-recalculate").click 54 | 55 | recalculate_scores_modal.apply.click 56 | 57 | expect(recalculate_scores_modal.status).to have_content( 58 | I18n.t("js.gamification.recalculating"), 59 | ) 60 | expect(recalculate_scores_modal).to have_button("apply-section", disabled: true) 61 | 62 | expect(Jobs::RecalculateScores.jobs.count).to eq(1) 63 | end 64 | end 65 | 66 | context "when admin does not have daily recalculation remaining" do 67 | it "disables the 'apply' button" do 68 | 5.times { DiscourseGamification::RecalculateScoresRateLimiter.perform! } 69 | 70 | visit("/admin/plugins/gamification") 71 | find(".leaderboard-admin__btn-recalculate").click 72 | 73 | expect(recalculate_scores_modal).to have_button("apply-section", disabled: true) 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /config/locales/server.nl.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | nl: 8 | site_settings: 9 | discourse_gamification_enabled: "Schakel de Discourse-gamificatie-plug-in" 10 | like_received_score_value: "De waarde van de aanmoediging die wordt toegekend wanneer een gebruiker een like ontvangt" 11 | like_given_score_value: "De waarde van de aanmoediging die wordt toegekend voor elke like die een gebruiker geeft" 12 | solution_score_value: "De waarde van de aanmoediging die wordt toegekend wanneer een bericht van een gebruiker wordt gemarkeerd als oplossing" 13 | user_invited_score_value: "De waarde van de aanmoediging die wordt toegekend wanneer een uitnodiging van een gebruiker wordt verzilverd" 14 | time_read_score_value: "De waarde van de aanmoediging die wordt toegekend voor elk uur besteed aan lezen" 15 | post_read_score_value: "De waarde van de aanmoediging die wordt toegekend voor elke honderd berichten die een gebruiker leest" 16 | topic_created_score_value: "De waarde van de aanmoediging die wordt toegekend wanneer een gebruiker een topic maakt" 17 | post_created_score_value: "De waarde van de aanmoediging die wordt toegekend wanneer een gebruiker een bericht maakt" 18 | flag_created_score_value: "De waarde van de aanmoediging die wordt toegekend wanneer een gebruiker een bericht markeert en de markering wordt geaccepteerd door een medewerker" 19 | day_visited_score_value: "De waarde van de aanmoediging die wordt toegekend voor elke dag dat een gebruiker de site bezoekt" 20 | scorable_categories: "Lijst van categorieën waar acties aanmoedigingen opleveren. Laat dit leeg om aanmoedigingen in te schakelen voor alle categorieën" 21 | reaction_received_score_value: "De waarde van de aanmoediging die wordt toegekend wanneer een gebruiker een reactie ontvangt" 22 | reaction_given_score_value: "De waarde van de aanmoediging die wordt toegekend voor elke reactie die een gebruiker geeft" 23 | chat_reaction_received_score_value: "De waarde van de aanmoediging die wordt toegekend wanneer een gebruiker een reactie ontvangt op een chatbericht" 24 | chat_reaction_given_score_value: "De waarde van de aanmoediging die wordt toegekend voor elke reactie die een gebruiker geeft op een chatbericht" 25 | chat_message_created_score_value: "De waarde van de aanmoediging die wordt toegekend voor elk bericht dat een gebruik stuurt in een chat" 26 | score_ranking_strategy: "Strategie voor bepaling van klassementspositie" 27 | score: "Aanmoedigingen" 28 | default_leaderboard_name: "Algemeen klassement" 29 | rate_limiter: 30 | by_type: 31 | recalculate_scores_remaining: "U hebt het maximale aantal scoreherberekeningen bereikt. Wacht %{time_left} voordat u het opnieuw probeert." 32 | errors: 33 | leaderboard_positions_not_ready: "We genereren je klassement. Probeer het over enkele minuten opnieuw." 34 | --------------------------------------------------------------------------------