Declaración del problema
Si utilizas el plugin de gamificación de Discourse para otorgar puntos a los usuarios por contribuir a tu comunidad, es posible que hayas detectado la necesidad de ajustar los valores de los puntos según las tendencias emergentes. En la Comunidad de desarrolladores de SailPoint, utilizamos el plugin de Gamificación para impulsar nuestro programa de embajadores. Los usuarios que realizan contribuciones valiosas a nuestra comunidad ganan puntos, los cuales a su vez se utilizan para determinar el nivel de beneficios que reciben. A medida que nuestra comunidad ha crecido y el número de contribuciones ha aumentado drásticamente, determinamos que los valores originales que asignamos a ciertos tipos de contribuciones necesitaban ser ajustados. Ajustar los valores de los puntos funciona bien si nunca realizas una recálculo de tus puntos, pero a menudo tenemos que recalcular los puntos cuando fusionamos usuarios o cuando utilizamos la API de gamificación externa para otorgar puntos por contribuciones pasadas. Si modificas los valores de los puntos de gamificación y realizas un recálculo, las contribuciones anteriores que se puntuaron con los valores antiguos ahora se puntuarán con los nuevos valores. Esto es un problema si deseas que los nuevos valores de puntos se asignen a nuevas contribuciones, mientras que las contribuciones antiguas permanezcan con el mismo valor. En esta guía, discutiré una solución que he creado para asegurar que los puntos anteriores de tus usuarios no se alteren cuando cambies los valores de los puntos y ejecutes un recálculo.
Utilizando el Explorador de Datos para calcular los puntos actuales y propuestos
Importar la consulta SQL
El primer paso en este proceso es importar la siguiente consulta SQL en tu plugin de Explorador de Datos. Esta consulta SQL fue modificada a partir de la consulta SQL original para incluir paginación, agregar columnas adicionales para user_name y name, y ordenar por user_id. Esta consulta generará una tabla de puntuaciones de usuarios para un rango de tiempo determinado y utilizando los valores de puntuación dados.
-- [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
Probar la consulta con tus valores de puntos actuales y propuestos
Una vez que hayas importado la consulta SQL, debes probarla utilizando un marco de tiempo que refleje el período en el que no deseas que los puntos cambien. Para nosotros, esto significaba desde el inicio de nuestro foro (2020-01-01) hasta el día antes de que quisiéramos realizar la actualización de nuestros valores de puntos (2024-05-15). Actualiza los valores de puntuación para reflejar tus valores de puntos actuales y prueba la consulta para asegurarte de que los valores sean correctos.
A continuación, mantén el mismo marco de tiempo pero cambia los valores de los puntos a los que deseas que sean. Toma nota de la diferencia en los valores para los mismos usuarios. En este ejemplo, Neil tenía un total de 1104 puntos (también conocidos como “cheers”) utilizando los valores de puntos actuales, y sus puntos aumentaron a 1245 utilizando los nuevos valores de puntos. Necesitamos un método para calcular la diferencia entre los puntos anteriores y posteriores y ajustar los puntos del usuario para que no vean un aumento o disminución en sus puntos después de implementar los nuevos valores de puntos.
Utilizando la API de gamificación externa para ajustar los puntos
Usando a Neil como ejemplo, sus puntos actuales son 1104 y sus puntos después del cambio propuesto serán 1245. Para asegurarnos de que no vea un aumento o disminución en sus puntos, necesitamos calcular la diferencia entre estos dos valores y luego asignar la diferencia a su puntuación. La diferencia se calcula como valorActual - valorNuevo, lo que en el caso de Neil sería 1104 - 1245 = -141. Esto significa que a Neil se le deben asignar -141 puntos. Podemos utilizar la API de gamificación externa para asignar estos puntos a su cuenta de usuario para que se reflejen en su puntuación en el tablero de clasificación. La llamada a la API que necesitamos hacer es la siguiente:
curl --location 'https://my.discourse.com/admin/plugins/gamification/score_events' \
--header 'Api-Key: <tu clave>' \
--header 'Api-Username: <tu nombre de usuario>' \
--header 'Content-Type: application/json' \
--data '{
"user_id": "101",
"date": "2024-05-15",
"points": "-141",
"description": "Ajuste de puntos de gamificación"
}'
Ejecutar esta llamada a la API ajustará la puntuación total de Neil para que los nuevos valores de puntos solo afecten a los nuevos eventos puntuables, mientras que cualquier diferencia en los eventos puntuables antiguos se cancelará con el ajuste. Ahora solo necesitamos aplicar este proceso a cada usuario de la comunidad.
Automatizando los ajustes de puntos
Para automatizar este proceso, he creado un script en Python que aprovecha las APIs de Discourse para ejecutar las consultas SQL, calcular la diferencia y asignar los ajustes a cada usuario. El script también genera un archivo CSV con un registro de todos los usuarios que recibieron un ajuste. Los comentarios en el script hacen un buen trabajo describiendo los valores que necesitas cambiar y cómo funciona. Si tienes alguna pregunta sobre cómo hacer que esto funcione, por favor deja un comentario a continuación.
Requisitos
Necesitarás una versión reciente de Python 3. Yo utilizo Python 3.9.6. También necesitarás instalar el paquete requests desde 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
# Las credenciales de la API se almacenan en un archivo secreto. Cómo suministrar los secretos depende de ti.
api_key = secrets.api_key
api_username = secrets.api_username
host = 'https://developer.sailpoint.com/discuss' # El nombre de host de tu instancia de Discourse
# Un enlace a un tema en tu foro que describa los cambios actuales y propuestos en los puntos. Es bueno tenerlo
# para que puedas consultarlo más tarde y entender qué cambió, así como hacer los cambios transparentes para tus usuarios.
point_adjustment_link = 'https://developer.sailpoint.com/discuss/t/update-to-ambassador-point-values-may-15th-2024/54178'
# El nombre que deseas darle a tu archivo CSV. La fecha de inicio y la fecha de fin se añadirán cuando se cree.
csv_name = 'sailpoint_developer_community_point_adjustment'
max_requests_per_minute = 200 # Máximo de solicitudes por período de 1 minuto. Ajusta esto para estar en o por debajo del límite de velocidad de la API de Discourse.
start_date = '2020-01-01' # El foro comenzó después de esta fecha, por lo que esto asegurará que se consideren todos los puntos.
# Esta fecha no puede ser hoy, o la consulta dará error. Esta fórmula utilizará la fecha de ayer. Puedes modificar esto
# para que sea más atrás en el pasado si lo deseas.
end_date = (date.today() - timedelta(days = 1)).strftime('%Y-%m-%d')
# El ID de tu consulta SQL. Puedes encontrarlo haciendo clic en tu consulta en el plugin de Explorador de Datos y luego buscando
# el parámetro "id" en la URL. Por ejemplo, mi ID es 66 como se muestra en esta URL:
# https://developer.sailpoint.com/discuss/admin/plugins/explorer?id=66
query_id = '66'
# Estos son los valores de puntos actuales para tu configuración de puntuación de Gamificación. Estos cuatro eran aplicables a nuestro foro
# pero puedes agregar los otros tipos de puntos puntuables si es necesario.
current_likes_received_score_value = '3'
current_solutions_score_value = '60'
current_posts_created_score_value = '6'
current_flag_created_score_value = '6'
# Estos son los valores de puntos propuestos que se aplicarán a todos los nuevos puntos.
new_likes_received_score_value = '6'
new_solutions_score_value = '60'
new_posts_created_score_value = '3'
new_flag_created_score_value = '6'
# Esta función ejecuta la consulta SQL para obtener los valores de puntos para todos los usuarios. Paginará automáticamente hasta que no haya más registros.
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']
# Paginar hasta llegar a la última página
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
# Crear el array de ajustes de puntos que deben realizarse. Si un usuario tiene 0 puntos, o no hay diferencia en los puntos, se
# filtrarán para que no hagamos llamadas innecesarias a la API. Este array incluirá toda la información necesaria para realizar
# la llamada a la API de gamificación externa, así como para poblar el archivo 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:
# Solo calcular la diferencia si hay una diferencia en los puntos.
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) # Eliminar el elemento de la lista para acelerar el procesamiento
break
else:
new_values.pop(i) # Eliminar el elemento de la lista para acelerar el procesamiento
break
return point_adjustments
# Guardar el array de ajustes de puntos en un archivo CSV para tu registro
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)
# Ejecutar la API de gamificación para asignar la diferencia en los puntos para cada usuario. Esta función se ralentizará intencionalmente
# para mantenerse dentro de tu límite de velocidad. En caso de que se alcance un límite de velocidad, esta función continuará esperando hasta
# que el límite de velocidad expire y pueda continuar. Si en algún momento hay un error en la llamada a la API, los ajustes de puntos
# completados con éxito se guardarán en un archivo CSV para que sepas cuáles están completos y dónde debes reanudar.
def assignPointAdjustments(point_adjustments):
assigned_point_adjustments = []
# Auto-limitación de velocidad para evitar afectar otras integraciones
sleep_time = 60 / max_requests_per_minute # Número de segundos a esperar entre cada llamada para mantenerse dentro del límite máximo de solicitudes
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'Ajustando puntos basados en nuevos valores de puntos de gamificación. Por favor, consulta {point_adjustment_link}.'
})
start = time.time() # Mantener un registro del tiempo de ejecución. Dado que las llamadas a la API tardan varios milisegundos, no contar este tiempo hacia el tiempo de espera máximo de solicitud.
try:
r = requests.post(endpoint, headers=headers, data=payload)
except Exception as e:
# En caso de fallo, guardar los ajustes asignados en un archivo CSV para saber cuáles ya están completos
exportCSV(assigned_point_adjustments)
raise
# Si el máximo de solicitudes es inferior a 300, no deberíamos alcanzar un límite de velocidad. Si llegamos a alcanzarlo, manejarlo apropiadamente.
while r.status_code == 429:
wait_time = r.json()["extras"]["wait_seconds"] + 1
print(f'Alcanzado el límite de velocidad. Durmiendo durante {wait_time} segundos')
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 caso de fallo, guardar los ajustes asignados en un archivo CSV para saber cuáles ya están completos
exportCSV(assigned_point_adjustments)
raise
if r.status_code != 200:
exportCSV(assigned_point_adjustments)
print(f'La última solicitud devolvió un error {r.status_code} con el siguiente mensaje de error\n{r.text}')
print('Cancelando ajustes y escribiendo los ajustes exitosos en el archivo')
raise Exception("HTTP Error")
end = time.time()
if end - start < sleep_time:
# Solo dormir si el tiempo de solicitud fue menor que el tiempo de espera máximo de solicitud
time.sleep(sleep_time - (end - start))
adjustment['external_gamification_point_id'] = r.json()["id"]
print(f'Asignado {adjustment["username"]} {adjustment["difference"]} puntos')
assigned_point_adjustments.append(adjustment)
return assigned_point_adjustments
limit = 1000 # Este es el límite máximo para consultas 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)
Los pasos para ejecutar el ajuste de puntos
Ahora que tenemos todas las piezas en su lugar, deberás seguir estos pasos para ejecutar el proceso de ajuste de puntos. Se recomienda que pruebes este proceso en una instancia de prueba de tu entorno de Discourse primero para resolver cualquier problema.
- Actualiza las variables en el script de Python con los valores deseados.
- Cambia los valores de los puntos en la configuración de Gamificación a los nuevos valores.
- Recalcula las puntuaciones para “Todo el tiempo”.
- Ejecuta el script de Python y espera a que se complete. Dependiendo de cuántos usuarios haya en tu sistema y cuál sea tu límite de velocidad, esto puede tardar varios minutos en completarse. Tuvimos 2,200 usuarios que necesitaban un ajuste. Con un límite de velocidad de 200 solicitudes/minuto, tardó unos 15 minutos en completarse.
- Una vez completado, recalcula las puntuaciones para “Todo el tiempo” nuevamente, solo por seguridad.
- Guarda el archivo CSV en un lugar seguro para tus registros, por si necesitas revertir cualquier cambio.
El recálculo de puntos puede tardar unas horas en completarse. Necesitarás tener paciencia para ver que los ajustes surtan efecto. Una vez que el recálculo esté completo, ahora has ajustado con éxito los puntos de tus usuarios, y los puntos futuros se calcularán a partir de los nuevos valores de puntos.
Realizando más ajustes en el futuro
Si en algún momento necesitas realizar ajustes a los valores de los puntos, puedes seguir el mismo proceso. La única diferencia es que necesitas establecer la start_date en la end_date de cuando ejecutaste por última vez el ajuste de puntos. Por ejemplo, la primera vez que ejecuté este ajuste de puntos establecí la end_date en 2024-05-15. Esto aseguró que todos los puntos ganados entre el inicio de mi foro y 2024-05-15 se ajustaran para preservar los valores antiguos. Supongamos que pasa un año y quiero cambiar los valores de los puntos nuevamente. Ahora necesito establecer la start_date en 2024-05-15 y la end_date en 2025-05-15. Esto asegurará que el ajuste de puntos solo se aplique a ese período y no sobrescriba nada de la primera vez que ejecutaste el ajuste de puntos.

