Énoncé du problème
Si vous utilisez le plugin Discourse Gamification pour attribuer des points aux utilisateurs qui contribuent à votre communauté, vous avez peut-être constaté la nécessité d’ajuster la valeur des points en fonction des tendances émergentes. Dans la communauté de développeurs SailPoint, nous utilisons le plugin Gamification pour alimenter notre programme d’Ambassadeurs. Les utilisateurs qui apportent des contributions précieuses à notre communauté gagnent des points, qui sont ensuite utilisés pour déterminer le niveau d’avantages dont ils bénéficient. À mesure que notre communauté s’est développée et que le nombre de contributions a considérablement augmenté, nous avons déterminé que les valeurs initiales que nous avions attribuées à certains types de contributions devaient être ajustées. L’ajustement des valeurs des points fonctionne bien si vous ne jamais ne recalculer vos points, mais nous devons souvent recalculer les points lors de la fusion d’utilisateurs ou lorsque nous utilisons l’API de gamification externe pour attribuer des points pour des contributions passées. Si vous modifiez les valeurs des points de gamification et effectuez un recalcul, les contributions précédentes qui avaient été notées avec les anciennes valeurs de points seront désormais notées avec les nouvelles valeurs. Cela pose problème si vous souhaitez que les nouvelles valeurs de points soient attribuées aux nouvelles contributions tout en conservant les anciennes contributions à la même valeur. Dans ce guide pratique, je discuterai d’une solution que j’ai créée pour garantir que les points précédents de vos utilisateurs ne seront pas modifiés lorsque vous changerez les valeurs des points et lancerez un recalcul.
Utiliser l’Explorateur de données pour calculer les points actuels et proposés
Importer la requête SQL
La première étape de ce processus consiste à importer la requête SQL suivante dans votre plugin Data Explorer. Cette requête SQL a été modifiée à partir de la requête SQL originale pour inclure la pagination, ajouter des colonnes supplémentaires pour user_name et name, et trier par user_id. Cette requête produira un tableau des scores des utilisateurs pour une plage de temps donnée et en utilisant les valeurs de score fournies.
-- [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
Tester la requête avec vos valeurs de points actuelles et proposées
Une fois la requête SQL importée, vous devez la tester en utilisant une période qui reflète la période pendant laquelle vous ne souhaitez pas que les points soient modifiés. Pour nous, cela signifiait du début de notre forum (2020-01-01) au jour précédant la date à laquelle nous souhaitions mettre à jour nos valeurs de points (2024-05-15). Mettez à jour les valeurs de score pour refléter vos valeurs de points actuelles, puis testez la requête pour vous assurer que les valeurs semblent correctes.
Ensuite, gardez la même période mais changez les valeurs de points pour celles que vous souhaitez. Notez la différence de valeurs pour les mêmes utilisateurs. Dans cet exemple, Neil avait un total de 1104 points (alias cheers) avec les valeurs de points actuelles, et ses points sont passés à 1245 avec les nouvelles valeurs de points. Nous avons besoin d’une méthode pour calculer la différence entre les points avant et après et ajuster les points de l’utilisateur afin qu’il ne constate ni augmentation ni diminution de ses points après la mise en œuvre des nouvelles valeurs de points.
Utiliser l’API de gamification externe pour ajuster les points
En prenant Neil comme exemple, ses points actuels sont de 1104 et ses points après le changement proposé seront de 1245. Pour nous assurer qu’il ne constate ni augmentation ni diminution de ses points, nous devons calculer la différence entre ces deux valeurs, puis attribuer cette différence à son score. La différence est calculée comme currentValue - newValue, ce qui, dans le cas de Neil, donnerait 1104 - 1245 = -141. Cela signifie que Neil doit se voir attribuer -141 points. Nous pouvons utiliser l’API de gamification externe pour attribuer ces points à son compte utilisateur afin qu’ils soient reflétés dans son score de classement. L’appel API que nous devons effectuer est le suivant :
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"
}'
L’exécution de cet appel API ajustera le score total de Neil de manière à ce que les nouvelles valeurs de points proposées n’affectent que les nouveaux événements notables, tandis que toute différence dans les anciens événements notables sera annulée par l’ajustement. Il ne nous reste plus qu’à appliquer ce processus à chaque utilisateur de la communauté.
Automatiser les ajustements de points
Pour automatiser ce processus, j’ai créé un script Python qui exploite les API de Discourse pour exécuter les requêtes SQL, calculer la différence et attribuer les ajustements à chaque utilisateur. Le script génère également un fichier CSV contenant un journal de tous les utilisateurs ayant reçu un ajustement. Les commentaires dans le script décrivent assez bien les valeurs que vous devez modifier et le fonctionnement du script. Si vous avez des questions sur la façon de le faire fonctionner, veuillez laisser un commentaire ci-dessous.
Prérequis
Vous aurez besoin d’une version récente de Python 3. J’utilise Python 3.9.6. Vous devrez également installer le package requests depuis PyPi.
python3 -m pip install requests
Script
import requests
import secrets
from datetime import date
from datetime import timedelta
import time
import csv
import json
# Les identifiants API sont stockés dans un fichier secret. La manière dont vous fournissez les secrets vous appartient.
api_key = secrets.api_key
api_username = secrets.api_username
host = 'https://developer.sailpoint.com/discuss' # Le nom d'hôte de votre instance Discourse
# Un lien vers un sujet de votre forum décrivant les changements actuels et proposés de points. C'est une bonne chose à avoir
# afin de pouvoir y revenir pour comprendre ce qui a changé, et de rendre les changements transparents pour vos utilisateurs.
point_adjustment_link = 'https://developer.sailpoint.com/discuss/t/update-to-ambassador-point-values-may-15th-2024/54178'
# Le nom que vous souhaitez donner à votre fichier CSV. Les start_date et end_date seront ajoutés lors de sa création.
csv_name = 'sailpoint_developer_community_point_adjustment'
max_requests_per_minute = 200 # Nombre maximal de requêtes par période de 1 minute. Ajustez cette valeur pour qu'elle soit égale ou inférieure à la limite de taux de l'API Discourse.
start_date = '2020-01-01' # Le forum a été lancé après cette date, ce qui garantit que tous les points sont pris en compte.
# Cette date ne peut pas être aujourd'hui, sinon la requête échouera. Cette formule utilisera la date d'hier. Vous pouvez la modifier
# pour qu'elle soit plus ancienne si nécessaire.
end_date = (date.today() - timedelta(days = 1)).strftime('%Y-%m-%d')
# L'ID de votre requête SQL. Vous pouvez le trouver en cliquant sur votre requête dans le plugin Data Explorer, puis en recherchant
# le paramètre "id" dans l'URL. Par exemple, mon ID est 66 comme indiqué dans cette URL :
# https://developer.sailpoint.com/discuss/admin/plugins/explorer?id=66
query_id = '66'
# Ce sont les valeurs de points actuelles pour vos paramètres de score de Gamification. Ces quatre étaient applicables à notre forum
# mais vous pouvez ajouter les autres types de points notables si nécessaire.
current_likes_received_score_value = '3'
current_solutions_score_value = '60'
current_posts_created_score_value = '6'
current_flag_created_score_value = '6'
# Ce sont les valeurs de points proposées qui seront appliquées à tous les nouveaux points.
new_likes_received_score_value = '6'
new_solutions_score_value = '60'
new_posts_created_score_value = '3'
new_flag_created_score_value = '6'
# Cette fonction exécute la requête SQL pour obtenir les valeurs de points pour tous les utilisateurs. Elle paginera automatiquement jusqu'à ce qu'il n'y ait plus d'enregistrements.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']
# Paginer jusqu'à la dernière 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
# Créer le tableau des ajustements de points à effectuer. Si un utilisateur a 0 point, ou s'il n'y a pas de différence de points, il
# sera filtré pour éviter d'appels API inutiles. Ce tableau inclura toutes les informations nécessaires pour effectuer
# l'appel à l'API de gamification externe ainsi que pour remplir le fichier 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:
# Ne calculer la différence que s'il y a une différence de 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) # Supprimer l'élément de la liste pour accélérer le traitement
break
else:
new_values.pop(i) # Supprimer l'élément de la liste pour accélérer le traitement
break
return point_adjustments
# Sauvegarder le tableau des ajustements de points dans un fichier CSV pour vos archives
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)
# Exécuter l'API de gamification pour attribuer la différence de points pour chaque utilisateur. Cette fonction ralentira intentionnellement
# afin de respecter votre limite de taux. Dans le cas où une limite de taux est atteinte, cette fonction continuera d'attendre jusqu'à ce que
# la limite de taux expire et qu'elle puisse continuer. Si à tout moment une erreur se produit dans l'appel API, les ajustements de points
# réussis seront sauvegardés dans un fichier CSV afin que vous sachiez lesquels sont terminés et où reprendre.
def assignPointAdjustments(point_adjustments):
assigned_point_adjustments = []
# Limite de taux automatique pour éviter d'affecter les autres intégrations
sleep_time = 60 / max_requests_per_minute # Nombre de secondes à attendre entre chaque appel pour rester dans la limite de requêtes maximale
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() # Suivre le temps d'exécution. Puisque les appels API prennent plusieurs millisecondes, ne comptez pas ce temps dans le temps d'attente maximal des requêtes.
try:
r = requests.post(endpoint, headers=headers, data=payload)
except Exception as e:
# En cas d'échec, sauvegarder les ajustements attribués dans un fichier CSV afin que nous sachions lesquels sont déjà terminés
exportCSV(assigned_point_adjustments)
raise
# Si la limite maximale de requêtes est inférieure à 300, nous ne devrions pas atteindre une limite de taux. Si nous atteignons la limite de taux, la gérer correctement.
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:
# En cas d'échec, sauvegarder les ajustements attribués dans un fichier CSV afin que nous sachions lesquels sont déjà terminés
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:
# Ne dormir que si le temps de requête était inférieur au temps d'attente maximal des requêtes
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 # C'est la limite maximale pour les requêtes 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)
Les étapes pour exécuter l’ajustement des points
Maintenant que nous avons tous les éléments en place, vous devrez suivre ces étapes pour exécuter le processus d’ajustement des points. Il est recommandé de tester ce processus dans une instance de test de votre environnement Discourse avant de le déployer, afin de résoudre tout problème.
- Mettez à jour les variables du script Python avec les valeurs souhaitées.
- Changez les valeurs de points dans les paramètres de Gamification pour les nouvelles valeurs.
- Recalculez les scores pour “Tout le temps”.
- Exécutez le script Python et attendez qu’il se termine. Selon le nombre d’utilisateurs dans votre système et votre limite de taux, cela peut prendre plusieurs minutes. Nous avions 2 200 utilisateurs qui nécessitaient un ajustement. Avec une limite de taux de 200 requêtes/minute, cela a pris environ 15 minutes pour se terminer.
- Une fois terminé, recalculez à nouveau les scores pour “Tout le temps”, juste pour être sûr.
- Sauvegardez le fichier CSV dans un endroit sûr pour vos archives, au cas où vous auriez besoin d’annuler des modifications.
Le recalcul des points peut prendre quelques heures pour se terminer. Vous devrez être patient pour voir les ajustements prendre effet. Une fois le recalcul terminé, vous avez maintenant réussi à ajuster les points de vos utilisateurs, et les points futurs seront calculés à partir des nouvelles valeurs de points.
Effectuer d’autres ajustements à l’avenir
Si à tout moment vous devez effectuer des ajustements aux valeurs de points, vous pouvez suivre le même processus. La seule différence est que vous devez définir la start_date sur la end_date de la dernière fois que vous avez exécuté l’ajustement des points. Par exemple, la première fois que j’ai exécuté cet ajustement des points, j’ai défini la end_date au 2024-05-15. Cela a assuré que tous les points gagnés entre le début de mon forum et le 2024-05-15 ont été ajustés pour préserver les anciennes valeurs. Supposons qu’une année s’écoule et que je veuille changer à nouveau les valeurs de points. Je dois maintenant définir la start_date au 2024-05-15 et la end_date au 2025-05-15. Cela garantira que l’ajustement des points ne s’applique qu’à cette période et ne remplace rien de la première fois où vous avez exécuté l’ajustement des points.

