Постановка задачи
Если вы используете плагин Discourse Gamification для начисления пользователям очков за вклад в развитие вашего сообщества, вы могли столкнуться с необходимостью корректировки значений очков в связи с появлением новых тенденций. В Сообществе разработчиков SailPoint мы используем плагин Gamification для реализации нашей программы послов. Пользователи, вносящие ценный вклад в наше сообщество, зарабатывают очки, которые затем используются для определения уровня привилегий, которые они получают. По мере роста нашего сообщества и резкого увеличения количества вкладов мы поняли, что первоначальные значения, назначенные за определенные виды деятельности, нуждаются в корректировке.
Корректировка значений очков работает хорошо, если вы никогда не пересчитываете свои очки, но нам часто приходится пересчитывать их при слиянии учетных записей пользователей или при использовании внешнего API геймификации для начисления очков за прошлые вклады. Если вы измените значения очков геймификации и выполните пересчет, то предыдущие вклады, оцененные по старым значениям очков, будут теперь оценены по новым. Это проблема, если вы хотите, чтобы новые значения очков применялись только к новым вкладам, оставляя старые вклады без изменений. В этом руководстве я расскажу о решении, которое я разработал, чтобы гарантировать, что предыдущие очки ваших пользователей не изменятся при смене значений очков и запуске пересчета.
Использование Data Explorer для расчета текущих и предполагаемых очков
Импорт SQL-запроса
Первым шагом в этом процессе является импорт следующего SQL-запроса в ваш плагин Data Explorer. Этот SQL-запрос был модифицирован на основе оригинального запроса для поддержки пагинации, добавления дополнительных колонок user_name и name, а также сортировки по user_id. Этот запрос создаст таблицу баллов пользователей за определенный временной диапазон с использованием заданных значений баллов.
-- [params]
-- date :start_date
-- date :end_date
-- int :day_visited_score_value = 0
-- int :time_read_score_value = 0
-- int :posts_read_score_value = 0
-- int :posts_created_score_value = 6
-- int :topics_created_score_value = 0
-- int :likes_received_score_value = 3
-- int :likes_given_score_value = 0
-- int :solutions_score_value = 60
-- int :flag_created_score_value = 6
-- int :user_invited_score_value = 0
-- int :limit = 1000
-- int :page = 0
WITH visits AS (
SELECT
uv.user_id,
COUNT(*) AS user_visits,
COUNT(*) * :day_visited_score_value AS visits_score
FROM user_visits uv
WHERE uv.visited_at BETWEEN :start_date AND :end_date
GROUP BY uv.user_id
),
time_read AS (
SELECT
uv.user_id,
SUM(uv.time_read) /3600 AS time_read,
SUM(uv.time_read) /3600 * :time_read_score_value AS time_read_score
FROM user_visits uv
WHERE uv.visited_at BETWEEN :start_date AND :end_date
AND uv.time_read >= 60
GROUP BY uv.user_id
),
posts_read AS (
SELECT
uv.user_id,
SUM(uv.posts_read) AS posts_read,
SUM(uv.posts_read) /100 * :posts_read_score_value AS posts_read_score
FROM user_visits uv
WHERE uv.visited_at BETWEEN :start_date AND :end_date
AND uv.posts_read >= 5
GROUP BY uv.user_id
),
posts_created AS (
SELECT
p.user_id,
COUNT(*) AS posts_created,
COUNT(*) * :posts_created_score_value AS posts_created_score
FROM posts p
INNER JOIN topics t ON t.id = p.topic_id
WHERE p.deleted_at IS NULL
AND t.archetype <> 'private_message'
AND p.wiki IS FALSE
AND p.created_at::date BETWEEN :start_date AND :end_date
GROUP BY p.user_id
),
topics_created AS (
SELECT
t.user_id,
COUNT(*) AS topics_created,
COUNT(*) * :topics_created_score_value AS topics_created_score
FROM topics t
WHERE t.deleted_at IS NULL
AND t.archetype <> 'private_message'
AND t.created_at::date BETWEEN :start_date AND :end_date
GROUP BY t.user_id
),
likes_received AS (
SELECT
p.user_id,
COUNT(*) AS likes_received,
COUNT(*) * :likes_received_score_value AS likes_received_score
FROM post_actions pa
INNER JOIN posts p ON p.id = pa.post_id
INNER JOIN topics t ON t.id = p.topic_id
WHERE p.deleted_at IS NULL
AND t.archetype <> 'private_message'
AND p.wiki IS FALSE
AND post_action_type_id = 2
AND pa.created_at::date BETWEEN :start_date AND :end_date
GROUP BY p.user_id
),
likes_given AS (
SELECT
pa.user_id AS user_id,
COUNT(*) AS likes_given,
COUNT(*) * :likes_given_score_value AS likes_given_score
FROM post_actions pa
INNER JOIN posts p ON p.id = pa.post_id
INNER JOIN topics t ON t.id = p.topic_id
WHERE p.deleted_at IS NULL
AND t.archetype <> 'private_message'
AND p.wiki IS FALSE
AND post_action_type_id = 2
AND pa.created_at::date BETWEEN :start_date AND :end_date
GROUP BY pa.user_id
),
solutions AS (
SELECT
p.user_id,
COUNT(st.topic_id) AS solutions,
COUNT(st.topic_id) * :solutions_score_value AS solutions_score
FROM discourse_solved_solved_topics st
INNER JOIN topics t ON st.topic_id = t.id
INNER JOIN posts p ON p.id = st.answer_post_id
WHERE p.deleted_at IS NULL
AND t.deleted_at IS NULL
AND t.archetype <> 'private_message'
AND p.user_id <> t.user_id
AND st.updated_at::date BETWEEN :start_date AND :end_date
GROUP BY p.user_id
),
flags AS (
SELECT
r.created_by_id AS user_id,
COUNT(*) AS flags,
COUNT(*) * :flag_created_score_value AS flags_score
FROM reviewables r
WHERE created_at::date BETWEEN :start_date AND :end_date
AND status = 1
GROUP BY user_id
),
invites AS (
SELECT
inv.invited_by_id AS user_id,
SUM(inv.redemption_count) AS invites,
(SUM(inv.redemption_count) * :user_invited_score_value)::int AS invites_score
FROM invites inv
WHERE inv.created_at::date BETWEEN :start_date AND :end_date
AND inv.redemption_count > 0
GROUP BY inv.invited_by_id
)
SELECT
u.id AS user_id,
u.username AS username,
u.name AS name,
(
COALESCE(v.visits_score,0) +
COALESCE(tr.time_read_score,0) +
COALESCE(pr.posts_read_score,0) +
COALESCE(pc.posts_created_score,0) +
COALESCE(tc.topics_created_score,0) +
COALESCE(lr.likes_received_score,0) +
COALESCE(lg.likes_given_score,0) +
COALESCE(s.solutions_score,0) +
COALESCE(f.flags_score,0) +
COALESCE(i.invites_score,0)
) AS "Total Cheers",
COALESCE(v.user_visits,0) || ' (' || COALESCE(v.visits_score,0) || ')' AS "Visits (cheers)",
COALESCE(tr.time_read,0) || 'hrs' || ' (' || COALESCE(tr.time_read_score,0) || ')' AS "Time Read (cheers)",
COALESCE(pr.posts_read,0) || ' (' || COALESCE(pr.posts_read_score,0) || ')' AS "Posts Read (cheers)",
COALESCE(pc.posts_created,0) || ' (' || COALESCE(pc.posts_created_score,0) || ')' AS "Posts Created (cheers)",
COALESCE(tc.topics_created,0) || ' (' || COALESCE(tc.topics_created_score,0) || ')'AS "Topics Created (cheers)",
COALESCE(lr.likes_received,0) || ' (' || COALESCE(lr.likes_received_score,0) || ')' AS "Likes Received (cheers)",
COALESCE(lg.likes_given,0) || ' (' || COALESCE(lg.likes_given_score,0) || ')' AS "Likes Given (cheers)",
COALESCE(s.solutions,0) || ' (' || COALESCE(s.solutions_score,0) || ')'AS "Solutions (cheers)",
COALESCE(f.flags,0) || ' (' || COALESCE(f.flags_score,0) || ')' AS "Agreed Flags (cheers)",
COALESCE(i.invites,0) || ' (' || COALESCE(i.invites_score,0) || ')' AS "Invites Redeemed (cheers)"
FROM users u
LEFT JOIN visits v ON v.user_id = u.id
LEFT JOIN posts_read pr USING (user_id)
LEFT JOIN time_read tr USING (user_id)
LEFT JOIN flags f USING (user_id)
LEFT JOIN posts_created pc USING (user_id)
LEFT JOIN topics_created tc USING (user_id)
LEFT JOIN likes_given lg USING (user_id)
LEFT JOIN likes_received lr USING (user_id)
LEFT JOIN solutions s USING (user_id)
LEFT JOIN invites i USING (user_id)
WHERE u.id > 0
AND u.id NOT IN (SELECT user_id FROM group_users WHERE group_id = 3)
ORDER BY user_id ASC
OFFSET :page * :limit
LIMIT :limit
Тестирование запроса с текущими и предполагаемыми значениями очков
После импорта SQL-запроса протестируйте его, используя временной промежуток, который отражает период, в течение которого вы не хотите изменять очки. Для нас это означало период с начала работы форума (2020-01-01) до дня, предшествующего обновлению значений очков (2024-05-15). Обновите значения баллов, чтобы они соответствовали вашим текущим значениям очков, и протестируйте запрос, чтобы убедиться, что значения выглядят корректно.
Далее, оставив тот же временной промежуток, измените значения очков на те, которые вы хотите использовать. Обратите внимание на разницу в значениях для одних и тех же пользователей. В этом примере у Нила было в общей сложности 1104 очка (также называемых «cheers») при использовании текущих значений очков, а при использовании новых значений очков его баллы увеличились до 1245. Нам нужен метод для расчета разницы между старыми и новыми очками и корректировки баллов пользователя так, чтобы после внедрения новых значений очков он не увидел ни увеличения, ни уменьшения своих очков.
Использование внешнего API геймификации для корректировки очков
Используя Нила в качестве примера: его текущие очки составляют 1104, а после предполагаемого изменения они станут 1245. Чтобы убедиться, что он не увидит ни увеличения, ни уменьшения очков, нам нужно рассчитать разницу между этими двумя значениями и присвоить эту разницу его счету. Разница рассчитывается как текущееЗначение - новоеЗначение, что в случае с Нилом равно 1104 - 1245 = -141. Это означает, что Нилу нужно начислить -141 очко. Мы можем использовать внешний API геймификации для начисления этих очков его учетной записи пользователя, чтобы они отразились в его счете на лидерборде. Необходимый вызов API выглядит следующим образом:
curl --location 'https://my.discourse.com/admin/plugins/gamification/score_events' \
--header 'Api-Key: <ваш ключ>' \
--header 'Api-Username: <ваше имя пользователя>' \
--header 'Content-Type: application/json' \
--data '{
"user_id": "101",
"date": "2024-05-15",
"points": "-141",
"description": "Gamification point adjustment"
}'
Выполнение этого вызова API скорректирует общий счет Нила так, что предполагаемые значения очков будут влиять только на новые события, подлежащие начислению баллов, а любая разница в старых событиях будет компенсирована корректировкой. Теперь нам нужно применить этот процесс ко всем пользователям сообщества.
Автоматизация корректировки очков
Для автоматизации этого процесса я создал скрипт на Python, который использует API Discourse для запуска SQL-запросов, расчета разницы и назначения корректировок каждому пользователю. Скрипт также выводит CSV-файл с журналом всех пользователей, получивших корректировку. Комментарии в скрипте довольно хорошо описывают значения, которые нужно изменить, и принцип работы. Если у вас возникнут вопросы о том, как это запустить, пожалуйста, оставьте комментарий ниже.
Требования
Вам понадобится последняя версия Python 3. Я использую Python 3.9.6. Также необходимо установить пакет requests из PyPi.
python3 -m pip install requests
Скрипт
import requests
import secrets
from datetime import date
from datetime import timedelta
import time
import csv
import json
# Учетные данные API хранятся в секретном файле. Способ передачи секретов зависит от вас.
api_key = secrets.api_key
api_username = secrets.api_username
host = 'https://developer.sailpoint.com/discuss' # Имя хоста вашего экземпляра Discourse
# Ссылка на тему в вашем форуме, описывающую текущие и предлагаемые изменения значений очков. Это полезно иметь
# чтобы вы могли вернуться к ней и понять, что изменилось, а также сделать изменения прозрачными для ваших пользователей.
point_adjustment_link = 'https://developer.sailpoint.com/discuss/t/update-to-ambassador-point-values-may-15th-2024/54178'
# Имя, которое вы хотите дать вашему CSV-файлу. start_date и end_date будут добавлены при его создании.
csv_name = 'sailpoint_developer_community_point_adjustment'
max_requests_per_minute = 200 # Максимальное количество запросов за 1 минуту. Отрегулируйте это значение так, чтобы оно соответствовало или было ниже вашего лимита скорости API Discourse.
start_date = '2020-01-01' # Форум был запущен после этой даты, поэтому это обеспечит учет всех очков.
# Эта дата не может быть сегодняшней, иначе запрос выдаст ошибку. Эта формула использует вчерашнюю дату. Вы можете изменить это
# на более раннюю дату, если это необходимо.
end_date = (date.today() - timedelta(days = 1)).strftime('%Y-%m-%d')
# ID вашего SQL-запроса. Вы можете найти его, нажав на свой запрос в плагине Data Explorer, а затем найдя
# параметр "id" в URL-адресе. Например, мой ID — 66, как показано в этом URL:
# https://developer.sailpoint.com/discuss/admin/plugins/explorer?id=66
query_id = '66'
# Это текущие значения очков для ваших настроек счета геймификации. Эти четыре параметра были применимы к нашему форуму,
# но вы можете добавить другие типы баллов, подлежащие начислению, если это необходимо.
current_likes_received_score_value = '3'
current_solutions_score_value = '60'
current_posts_created_score_value = '6'
current_flag_created_score_value = '6'
# Это предлагаемые значения очков, которые будут применены ко всем новым очкам.
new_likes_received_score_value = '6'
new_solutions_score_value = '60'
new_posts_created_score_value = '3'
new_flag_created_score_value = '6'
# Эта функция запускает SQL-запрос для получения значений очков для всех пользователей. Она автоматически выполняет пагинацию, пока не закончатся записи.
def getPointValues(query_id, start_date, end_date, limit, likes_received_score_value, solutions_score_value, posts_created_score_value, flag_created_score_value):
rows = []
page = 0
headers = {
'Api-Key': api_key,
'Api-Username': api_username,
'Content-Type': 'application/x-www-form-urlencoded'
}
payload = f'params={{"start_date":"{start_date}","end_date":"{end_date}","limit":"{str(limit)}","page":"{str(page)}","likes_received_score_value":"{likes_received_score_value}","solutions_score_value":"{solutions_score_value}","posts_created_score_value":"{posts_created_score_value}","flag_created_score_value":"{flag_created_score_value}"}}'
r = requests.post(f'{host}/admin/plugins/explorer/queries/{query_id}/run', headers=headers, data=payload).json()
rows += r['rows']
# Пагинация до достижения последней страницы
while len(r['rows']) == limit:
page += 1
payload = f'params={{"start_date":"{start_date}","end_date":"{end_date}","limit":"{str(limit)}","page":"{str(page)}","likes_received_score_value":"{likes_received_score_value}","solutions_score_value":"{solutions_score_value}","posts_created_score_value":"{posts_created_score_value}","flag_created_score_value":"{flag_created_score_value}"}}'
r = requests.post(f'{host}/admin/plugins/explorer/queries/{query_id}/run', headers=headers, data=payload).json()
rows += r['rows']
return rows
# Создает массив корректировок очков, которые необходимо внести. Если у пользователя 0 очков или нет разницы в очках, они
# будут отфильтрованы, чтобы мы не делали ненужные вызовы API. Этот массив будет включать всю необходимую информацию для выполнения
# вызова внешнего API геймификации, а также для заполнения CSV-файла.
def calculatePointAdjustments(old_values, new_values):
point_adjustments = []
for row in old_values:
user_id = row[0]
username = row[1]
name = row[2]
current_cheers = row[3]
for i in range(len(new_values)):
new_cheers = new_values[i][3]
if new_values[i][0] == user_id:
# Рассчитывать разницу только если есть разница в очках.
if current_cheers != new_cheers:
point_adjustments.append({
'user_id': user_id,
'username': username,
'name': name,
'current_points': current_cheers,
'new_points': new_cheers,
'difference': current_cheers - new_cheers,
'external_gamification_point_id': -1
})
new_values.pop(i) # Удалить элемент из списка для ускорения обработки
break
else:
new_values.pop(i) # Удалить элемент из списка для ускорения обработки
break
return point_adjustments
# Сохраняет массив корректировок очков в CSV-файл для ведения учета
def exportCSV(differences):
fields = ['User ID', 'Username', 'Name', 'Current Points', 'New Points', 'Difference', 'External Gamification Point ID']
rows = []
for diff in differences:
rows.append([diff['user_id'], diff['username'], diff['name'], diff['current_points'], diff['new_points'], diff['difference'], diff['external_gamification_point_id']])
with open(f'{csv_name}_{start_date}_to_{end_date}.csv', 'w') as f:
csv_writer = csv.writer(f)
csv_writer.writerow(fields)
csv_writer.writerows(rows)
# Выполняет вызов API геймификации для назначения разницы в очках для каждого пользователя. Эта функция намеренно замедляет работу,
# чтобы оставаться в пределах вашего лимита скорости. В случае достижения лимита скорости эта функция продолжит ожидание до
# истечения лимита и сможет продолжить. Если в любой момент произойдет ошибка в вызове API, то успешно выполненные
# корректировки очков будут сохранены в CSV-файл, чтобы вы знали, какие из них завершены и где нужно возобновить работу.
def assignPointAdjustments(point_adjustments):
assigned_point_adjustments = []
# Собственное ограничение скорости, чтобы не влиять на другие интеграции
sleep_time = 60 / max_requests_per_minute # Количество секунд ожидания между каждым вызовом для соблюдения максимального лимита запросов
endpoint = f'{host}/admin/plugins/gamification/score_events'
headers = {
'Api-Key': api_key,
'Api-Username': api_username,
'Content-Type': 'application/json'
}
for adjustment in point_adjustments:
payload = json.dumps({
"user_id": str(adjustment['user_id']),
"date": end_date,
"points": str(adjustment['difference']),
"description": f'Adjusting points based on new gamification point values. Please see {point_adjustment_link}.'
})
start = time.time() # Отслеживание времени выполнения. Поскольку вызовы API занимают несколько миллисекунд, не учитывайте это время в максимальном времени ожидания между запросами.
try:
r = requests.post(endpoint, headers=headers, data=payload)
except Exception as e:
# В случае сбоя сохраните назначенные корректировки в CSV-файл, чтобы знать, какие из них уже выполнены
exportCSV(assigned_point_adjustments)
raise
# Если максимальное количество запросов ниже 300, мы не должны достигать лимита скорости. Если мы все же достигнем его, обработайте это соответствующим образом.
while r.status_code == 429:
wait_time = r.json()["extras"]["wait_seconds"] + 1
print(f'Hit rate limit. Sleeping for {wait_time} seconds')
time.sleep(wait_time)
try:
r = requests.post(endpoint, headers=headers, data=payload)
adjustment['external_gamification_point_id'] = r.json()["id"]
except Exception as e:
# В случае сбоя сохраните назначенные корректировки в CSV-файл, чтобы знать, какие из них уже выполнены
exportCSV(assigned_point_adjustments)
raise
if r.status_code != 200:
exportCSV(assigned_point_adjustments)
print(f'The last request returned a {r.status_code} error with the following error message\n{r.text}')
print('Aborting adjustments and writing the successful adjustments to file')
raise Exception("HTTP Error")
end = time.time()
if end - start < sleep_time:
# Спать только если время запроса было меньше максимального времени ожидания между запросами
time.sleep(sleep_time - (end - start))
adjustment['external_gamification_point_id'] = r.json()["id"]
print(f'Assigned {adjustment["username"]} {adjustment["difference"]} points')
assigned_point_adjustments.append(adjustment)
return assigned_point_adjustments
limit = 1000 # Это максимальный лимит для SQL-запросов.
current_values = getPointValues(query_id, start_date, end_date, limit, current_likes_received_score_value, current_solutions_score_value, current_posts_created_score_value, current_flag_created_score_value)
new_values = getPointValues(query_id, start_date, end_date, limit, new_likes_received_score_value, new_solutions_score_value, new_posts_created_score_value, new_flag_created_score_value)
point_adjustments = calculatePointAdjustments(current_values, new_values)
assigned_point_adjustments = assignPointAdjustments(point_adjustments)
exportCSV(assigned_point_adjustments)
Шаги для выполнения корректировки очков
Теперь, когда все компоненты готовы, вам нужно выполнить следующие шаги для запуска процесса корректировки очков. Настоятельно рекомендуется протестировать этот процесс в тестовом экземпляре вашей среды Discourse, чтобы устранить любые проблемы.
- Обновите переменные в скрипте Python до нужных вам значений.
- Измените значения очков в настройках геймификации на новые значения.
- Пересчитайте баллы за «Всё время».
- Запустите скрипт Python и дождитесь его завершения. В зависимости от количества пользователей в вашей системе и вашего лимита скорости это может занять несколько минут. У нас было 2200 пользователей, нуждавшихся в корректировке. При лимите скорости 200 запросов в минуту это заняло около 15 минут.
- После завершения еще раз пересчитайте баллы за «Всё время», на всякий случай.
- Сохраните CSV-файл в надежном месте для ваших записей, на случай, если вам потребуется откатить какие-либо изменения.
Пересчет очков может занять несколько часов. Вам нужно будет запастись терпением, чтобы увидеть, как вступят в силу корректировки. После завершения пересчета вы успешно скорректировали очки ваших пользователей, и будущие очки будут рассчитываться на основе новых значений.
Будущие корректировки
Если в какой-то момент вам потребуется внести корректировки в значения очков, вы можете следовать тому же процессу. Единственное отличие заключается в том, что вам нужно установить start_date равным end_date последнего запуска корректировки очков. Например, в первый раз я запускал эту корректировку, установив end_date на 2024-05-15. Это гарантировало, что все очки, заработанные с начала моего форума до 2024-05-15, были скорректированы для сохранения старых значений. Предположим, что прошел год, и я хочу снова изменить значения очков. Теперь мне нужно установить start_date на 2024-05-15, а end_date — на 2025-05-15. Это обеспечит применение корректировки очков только к этому периоду и не перезапишет ничего из первого раза, когда вы запускали корректировку.

