Как настроить игровые очки, не обновляя их значения ретроспективно для старых действий

Постановка задачи

Если вы используете плагин 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, чтобы устранить любые проблемы.

  1. Обновите переменные в скрипте Python до нужных вам значений.
  2. Измените значения очков в настройках геймификации на новые значения.
  3. Пересчитайте баллы за «Всё время».
  4. Запустите скрипт Python и дождитесь его завершения. В зависимости от количества пользователей в вашей системе и вашего лимита скорости это может занять несколько минут. У нас было 2200 пользователей, нуждавшихся в корректировке. При лимите скорости 200 запросов в минуту это заняло около 15 минут.
  5. После завершения еще раз пересчитайте баллы за «Всё время», на всякий случай.
  6. Сохраните CSV-файл в надежном месте для ваших записей, на случай, если вам потребуется откатить какие-либо изменения.

Пересчет очков может занять несколько часов. Вам нужно будет запастись терпением, чтобы увидеть, как вступят в силу корректировки. После завершения пересчета вы успешно скорректировали очки ваших пользователей, и будущие очки будут рассчитываться на основе новых значений.

Будущие корректировки

Если в какой-то момент вам потребуется внести корректировки в значения очков, вы можете следовать тому же процессу. Единственное отличие заключается в том, что вам нужно установить start_date равным end_date последнего запуска корректировки очков. Например, в первый раз я запускал эту корректировку, установив end_date на 2024-05-15. Это гарантировало, что все очки, заработанные с начала моего форума до 2024-05-15, были скорректированы для сохранения старых значений. Предположим, что прошел год, и я хочу снова изменить значения очков. Теперь мне нужно установить start_date на 2024-05-15, а end_date — на 2025-05-15. Это обеспечит применение корректировки очков только к этому периоду и не перезапишет ничего из первого раза, когда вы запускали корректировку.

6 лайков