문제 정의
Discourse Gamification Plugin을 사용하여 커뮤니티에 기여한 사용자에게 포인트를 부여하는 경우, 새로운 트렌드에 따라 포인트 값을 조정해야 할 필요가 있을 수 있습니다. SailPoint Developer Community에서는 Ambassador program을 운영하기 위해 Gamification Plugin을 사용합니다. 커뮤니티에 가치 있는 기여를 하는 사용자는 포인트를 획득하며, 이 포인트는 사용자가 받는 혜택의 수준을 결정하는 데 사용됩니다. 커뮤니티가 성장하고 기여 수가 급격히 증가하면서, 특정 유형의 기여에 대해 원래 할당했던 값들이 조정되어야 한다고 판단했습니다. 포인트 값을 조정하는 것은 포인트를 다시 계산하지 않는 한 잘 작동하지만, 사용자 병합을 수행하거나 외부 게임화 API를 사용하여 과거 기여에 대한 포인트를 부여할 때 포인트를 다시 계산해야 하는 경우가 많습니다. 게임화 포인트 값을 수정하고 다시 계산을 수행하면,旧的 포인트 값으로 점수화된 이전 기여들이 새로운 포인트 값으로 점수화됩니다. 이는 새로운 포인트 값이 새로운 기여에만 할당되도록 하고, 이전 기여는 동일한 값으로 유지하려는 경우 문제가 됩니다. 이 가이드에서는 포인트 값을 변경하고 다시 계산을 실행할 때 사용자의 이전 포인트가 변경되지 않도록 보장하는 제가 만든 솔루션에 대해 논의하겠습니다.
Data Explorer를 사용하여 현재 및 제안된 포인트 계산
SQL 쿼리 가져오기
이 프로세스의 첫 번째 단계는 다음 SQL 쿼리를 Data Explorer 플러그인에 가져오는 것입니다. 이 SQL 쿼리는 원본 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)까지였습니다. 점수 값을 현재 포인트 값으로 업데이트하고 값이 올바른지 확인하기 위해 쿼리를 테스트하십시오.
다음으로, 동일한 시간 범위를 유지하되 포인트 값을 원하는 값으로 변경하십시오. 동일한 사용자의 값 차이를 확인하십시오. 이 예시에서 Neil은 현재 포인트 값을 사용하면 총 1104점(즉, cheers)을 가지고 있었으며, 새로운 포인트 값을 사용하면 1245점으로 증가했습니다. 새로운 포인트 값이 적용된 후 사용자의 포인트가 증가하거나 감소하지 않도록 하기 위해, 이전과 이후 포인트의 차이를 계산하고 사용자의 포인트를 조정하는 방법이 필요합니다.
외부 게임화 API를 사용하여 포인트 조정
Neil을 예로 들면, 그의 현재 포인트는 1104점이고 제안된 변경 후 포인트는 1245점입니다. 포인트가 증가하거나 감소하지 않도록 하려면 이 두 값의 차이를 계산한 후 그 차이를 그의 점수에 할당해야 합니다. 차이는 currentValue - newValue로 계산되며, Neil의 경우 1104 - 1245 = -141이 됩니다. 이는 Neil에게 -141점을 할당해야 함을 의미합니다. 외부 게임화 API를 사용하여 이 포인트를 그의 사용자 계정에 할당하여 리더보드 점수에 반영할 수 있습니다. 실행해야 하는 API 호출은 다음과 같습니다:
curl --location 'https://my.discourse.com/admin/plugins/gamification/score_events' \
--header 'Api-Key: <your key>' \
--header 'Api-Username: <your username>' \
--header 'Content-Type: application/json' \
--data '{
"user_id": "101",
"date": "2024-05-15",
"points": "-141",
"description": "Gamification point adjustment"
}'
이 API 호출을 실행하면 Neil의 총 점수가 조정되어 제안된 포인트 값이 새로운 점수화 가능한 이벤트에만 영향을 주게 되며,旧的 점수화 가능한 이벤트의 차이는 조정으로 상쇄됩니다. 이제 이 프로세스를 커뮤니티의 모든 사용자에게 적용하기만 하면 됩니다.
포인트 조정 자동화
이 프로세스를 자동화하기 위해, SQL 쿼리를 실행하고 차이를 계산하며 각 사용자에게 조정을 할당하는 Discourse API를 활용하는 Python 스크립트를 작성했습니다. 이 스크립트는 조정을 받은 모든 사용자의 로그를 포함하는 CSV 파일도 출력합니다. 스크립트의 주석은 변경해야 하는 값과 작동 방식을 잘 설명하고 있습니다. 작동 방법에 대해 질문이 있으면 아래에 댓글을 남겨 주세요.
요구 사항
최근 버전의 Python 3이 필요합니다. 저는 Python 3.9.6을 사용합니다. 또한 PyPi에서 requests 패키지를 설치해야 합니다.
python3 -m pip install requests
스크립트
import requests
import secrets
from datetime import date
from datetime import timedelta
import time
import csv
import json
# The API credentials are store in a secret file. How you supply the secrets are up to you.
api_key = secrets.api_key
api_username = secrets.api_username
host = 'https://developer.sailpoint.com/discuss' # The hostname of your Discourse instance
# A link to a topic in your forum describing the current and proposed point changes. This is a good thing to have
# so you can look back on it to understand what changed, as well as make the changes transparent to your users.
point_adjustment_link = 'https://developer.sailpoint.com/discuss/t/update-to-ambassador-point-values-may-15th-2024/54178'
# The name you want to give your CSV file. The start_date and end_date will be appended when it is created.
csv_name = 'sailpoint_developer_community_point_adjustment'
max_requests_per_minute = 200 # Max requests per 1 minute period. Adjust this to be at or below your Discourse API rate limit.
start_date = '2020-01-01' # The forum started after this date, so this will ensure that all points are considered.
# This date cannot be today, or the query will error out. This formula will use yesterday's date. You can modify this
# to be further in the past if desired.
end_date = (date.today() - timedelta(days = 1)).strftime('%Y-%m-%d')
# The ID of your SQL query. You can find this by clicking on your query in the Data Explorer plugin and then looking for
# the "id" param in the URL. For example, my ID is 66 as shown in this URL:
# https://developer.sailpoint.com/discuss/admin/plugins/explorer?id=66
query_id = '66'
# These are the current point values for your Gamification score settings. These four were applicable to our forum
# but you can add the other score-able point types if you need to.
current_likes_received_score_value = '3'
current_solutions_score_value = '60'
current_posts_created_score_value = '6'
current_flag_created_score_value = '6'
# These are the proposed point values that will be applied to all new points.
new_likes_received_score_value = '6'
new_solutions_score_value = '60'
new_posts_created_score_value = '3'
new_flag_created_score_value = '6'
# This function runs the SQL query to get the point values for all users. It will automatically paginate until there are no more records.
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']
# Paginate until we reach last page
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
# Create the array of point adjustments that need to be made. If a user has 0 points, or there is no difference in points, they
# will be filtered out so we don't make unnecessary API calls. This array will include all of the information necessary to make
# the external gamification API call as well as populate the CSV file.
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:
# Only calculate the diff if there is a difference in points.
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) # Remove the element from the list to speed up processing
break
else:
new_values.pop(i) # Remove the element from the list to speed up processing
break
return point_adjustments
# Save the point adjustment array to a CSV file for your record keeping
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)
# Execute the gamification API to assign the difference in points for each user. This function will intentionally slow down
# in order to stay within your rate limit. In the event a rate limit is reached, this function will continue to wait until
# the rate limit is expired and it can continue. If at any time there is an error in the API call, then the succesffully completed
# point adjustments will be saved to a CSV file so you know which ones are complete and where you need to resume.
def assignPointAdjustments(point_adjustments):
assigned_point_adjustments = []
# Self rate limit to avoid affecting other integrations
sleep_time = 60 / max_requests_per_minute # Number of seconds to wait between each call to stay within the max requests limit
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() # Keep track of execution time. Since API calls take several milliseconds, don't count this time towards the max request sleep time.
try:
r = requests.post(endpoint, headers=headers, data=payload)
except Exception as e:
# In case of failure, save the assigned adjustments to a CSV file so we know which ones are already complete
exportCSV(assigned_point_adjustments)
raise
# If the max requests is below 300, then we shouldn't hit a rate limit. If we happen to hit the rate limit, handle it appropriately.
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:
# In case of failure, save the assigned adjustments to a CSV file so we know which ones are already complete
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:
# Only sleep if the request time was less than the max request 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 # This is the max limit for SQL queries.
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 스크립트의 변수를 원하는 값으로 업데이트합니다.
- Gamification 설정에서 포인트 값을 새로운 값으로 변경합니다.
- "All Time"에 대한 점수를 다시 계산합니다.
- Python 스크립트를 실행하고 완료될 때까지 기다립니다. 시스템의 사용자 수와 레이트 제한에 따라 완료하는 데 몇 분이 걸릴 수 있습니다. 저희는 조정이 필요한 사용자가 2,200명이었습니다. 분당 200건의 요청 레이트 제한으로 인해 완료하는 데 약 15분이 걸렸습니다.
- 완료되면 안전을 위해 "All Time"에 대한 점수를 한 번 더 다시 계산합니다.
- 변경 사항을 롤백해야 할 경우를 대비하여 기록을 위해 CSV 파일을 안전한 곳에 저장합니다.
포인트 재계산에는 몇 시간이 걸릴 수 있습니다. 조정이 적용되는 것을 보려면 인내심을 가져야 합니다. 재계산이 완료되면 사용자의 포인트를 성공적으로 조정했으며, 앞으로의 포인트는 새로운 포인트 값에서 계산됩니다.
향후 추가 조정
언제든 포인트 값에 조정이 필요하면 동일한 프로세스를 따를 수 있습니다. 유일한 차이는 start_date를 마지막으로 포인트 조정을 실행한 시점의 end_date로 설정해야 한다는 것입니다. 예를 들어, 이 포인트 조정을 처음 실행할 때 end_date를 2024-05-15로 설정했습니다. 이는 포럼의 시작부터 2024-05-15까지 획득된 모든 포인트가旧的 값이 보존되도록 조정되었음을 보장했습니다. 1년이 지나고 포인트 값을 다시 변경하고 싶다고 가정해 보겠습니다. 이제 start_date를 2024-05-15로, end_date를 2025-05-15로 설정해야 합니다. 이렇게 하면 포인트 조정이 해당 기간에만 적용되고 처음 포인트 조정을 실행한 시점의 내용을 덮어쓰지 않도록 보장됩니다.

