Declaração do problema
Se você usa o Plugin de Gamificação do Discourse para conceder pontos aos usuários por contribuições à sua comunidade, pode ter percebido a necessidade de ajustar os valores dos pontos com base em tendências emergentes. Na Comunidade de Desenvolvedores da SailPoint, utilizamos o Plugin de Gamificação para impulsionar nosso Programa de Embaixadores. Usuários que fazem contribuições valiosas à nossa comunidade ganham pontos, que por sua vez são usados para determinar o nível de benefícios que recebem. À medida que nossa comunidade cresceu e o número de contribuições aumentou drasticamente, determinamos que os valores originais atribuídos a certos tipos de contribuições precisavam ser ajustados.
Ajustar os valores dos pontos funciona bem se você nunca fizer uma recálculo dos seus pontos, mas frequentemente precisamos recalcular os pontos ao realizar mesclagens de usuários ou ao usar a API de gamificação externa para conceder pontos por contribuições passadas. Se você modificar os valores dos pontos de gamificação e fizer um recálculo, as contribuições anteriores que foram pontuadas com os valores antigos agora serão pontuadas com os novos valores. Isso é um problema se você deseja que os novos valores de pontos sejam atribuídos apenas a novas contribuições, mantendo as contribuições antigas com o mesmo valor. Neste guia prático, discutirei uma solução que criei para garantir que os pontos anteriores dos seus usuários não sejam alterados quando você mudar os valores dos pontos e executar um recálculo.
Usando o Data Explorer para calcular pontos atuais e propostos
Importar a consulta SQL
A primeira etapa desse processo é importar a seguinte consulta SQL para o seu plugin Data Explorer. Esta consulta SQL foi modificada a partir da consulta SQL original para incluir paginação, adicionar colunas adicionais para user_name e name, e ordenar por user_id. Esta consulta produzirá uma tabela de pontuações dos usuários para um determinado intervalo de tempo e usando os valores de pontuação fornecidos.
-- [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
Testar a consulta usando seus valores de pontos atuais e propostos
Depois de importar a consulta SQL, você deve testá-la usando um período que reflita o intervalo no qual você não deseja que os pontos sejam alterados. Para nós, isso significava desde o início do nosso fórum (2020-01-01) até o dia anterior ao que desejávamos fazer a atualização dos nossos valores de pontos (2024-05-15). Atualize os valores de pontuação para refletir seus valores de pontos atuais e teste a consulta para garantir que os valores pareçam corretos.
Em seguida, mantenha o mesmo período, mas altere os valores dos pontos para o que você deseja que sejam. Observe a diferença nos valores para os mesmos usuários. Neste exemplo, Neil tinha um total de 1104 pontos (também conhecidos como cheers) usando os valores de pontos atuais, e seus pontos aumentaram para 1245 usando os novos valores de pontos. Precisamos de um método para calcular a diferença nos pontos antes e depois e ajustar os pontos do usuário para que ele não veja um aumento ou diminuição nos pontos após a implementação dos novos valores de pontos.
Usando a API de gamificação externa para ajustar os pontos
Usando Neil como exemplo, seus pontos atuais são 1104 e seus pontos após a alteração proposta serão 1245. Para garantir que ele não veja um aumento ou diminuição nos pontos, precisamos calcular a diferença entre esses dois valores e, em seguida, atribuir essa diferença à pontuação dele. A diferença é calculada como valorAtual - valorNovo, que no caso de Neil seria 1104 - 1245 = -141. Isso significa que Neil precisa receber -141 pontos. Podemos usar a API de gamificação externa para atribuir esses pontos à conta de usuário dele, de modo que eles sejam refletidos na pontuação do ranking dele. A chamada de API que precisamos fazer é a seguinte:
curl --location 'https://my.discourse.com/admin/plugins/gamification/score_events' \
--header 'Api-Key: <sua_chave>' \
--header 'Api-Username: <seu_usuario>' \
--header 'Content-Type: application/json' \
--data '{
"user_id": "101",
"date": "2024-05-15",
"points": "-141",
"description": "Ajuste de pontos de gamificação"
}'
Executar esta chamada de API ajustará a pontuação total de Neil, de modo que os valores de pontos propostos afetarão apenas novos eventos pontuáveis, enquanto qualquer diferença nos eventos pontuáveis antigos será cancelada pelo ajuste. Agora, precisamos apenas aplicar esse processo a todos os usuários da comunidade.
Automatizando os ajustes de pontos
Para automatizar esse processo, criei um script Python que aproveita as APIs do Discourse para executar as consultas SQL, calcular a diferença e atribuir os ajustes a cada usuário. O script também gera um arquivo CSV com um registro de todos os usuários que receberam um ajuste. Os comentários no script fazem um bom trabalho descrevendo os valores que você precisa alterar e como ele funciona. Se houver alguma dúvida sobre como fazer isso funcionar, deixe um comentário abaixo.
Requisitos
Você precisará de uma versão recente do Python 3. Eu uso o Python 3.9.6. Você também precisará instalar o pacote requests do 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
# As credenciais da API são armazenadas em um arquivo secreto. Como você fornecerá os segredos fica a seu critério.
api_key = secrets.api_key
api_username = secrets.api_username
host = 'https://developer.sailpoint.com/discuss' # O nome de host da sua instância do Discourse
# Um link para um tópico no seu fórum descrevendo as alterações atuais e propostas nos pontos. Isso é algo bom de ter
# para que você possa olhar para trás e entender o que mudou, além de tornar as alterações transparentes para seus usuários.
point_adjustment_link = 'https://developer.sailpoint.com/discuss/t/update-to-ambassador-point-values-may-15th-2024/54178'
# O nome que você deseja dar ao seu arquivo CSV. A data de início e a data de fim serão adicionadas quando for criado.
csv_name = 'sailpoint_developer_community_point_adjustment'
max_requests_per_minute = 200 # Máximo de solicitações por período de 1 minuto. Ajuste isso para estar no limite ou abaixo do limite de taxa da API do Discourse.
start_date = '2020-01-01' # O fórum foi iniciado após esta data, então isso garantirá que todos os pontos sejam considerados.
# Esta data não pode ser hoje, ou a consulta dará erro. Esta fórmula usará a data de ontem. Você pode modificá-la
# para ser mais antiga no passado, se desejar.
end_date = (date.today() - timedelta(days = 1)).strftime('%Y-%m-%d')
# O ID da sua consulta SQL. Você pode encontrá-lo clicando na sua consulta no plugin Data Explorer e procurando
# o parâmetro "id" na URL. Por exemplo, meu ID é 66, conforme mostrado nesta URL:
# https://developer.sailpoint.com/discuss/admin/plugins/explorer?id=66
query_id = '66'
# Estes são os valores de pontos atuais para suas configurações de pontuação de Gamificação. Estes quatro eram aplicáveis ao nosso fórum,
# mas você pode adicionar os outros tipos de pontos pontuáveis se precisar.
current_likes_received_score_value = '3'
current_solutions_score_value = '60'
current_posts_created_score_value = '6'
current_flag_created_score_value = '6'
# Estes são os valores de pontos propostos que serão aplicados a todos os novos pontos.
new_likes_received_score_value = '6'
new_solutions_score_value = '60'
new_posts_created_score_value = '3'
new_flag_created_score_value = '6'
# Esta função executa a consulta SQL para obter os valores de pontos para todos os usuários. Ela paginará automaticamente até que não haja mais 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 até chegarmos à ú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
# Crie o array de ajustes de pontos que precisam ser feitos. Se um usuário tiver 0 pontos, ou se não houver diferença nos pontos, eles
# serão filtrados para que não façamos chamadas de API desnecessárias. Este array incluirá todas as informações necessárias para fazer
# a chamada à API de gamificação externa, bem como preencher o arquivo 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:
# Calcular a diferença apenas se houver uma diferença nos pontos.
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) # Remover o elemento da lista para acelerar o processamento
break
else:
new_values.pop(i) # Remover o elemento da lista para acelerar o processamento
break
return point_adjustments
# Salvar o array de ajustes de pontos em um arquivo CSV para seus registros
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)
# Executar a API de gamificação para atribuir a diferença nos pontos para cada usuário. Esta função intencionalmente desacelerará
# para permanecer dentro do seu limite de taxa. Caso um limite de taxa seja atingido, esta função continuará aguardando até
# que o limite de taxa expire e ela possa continuar. Se a qualquer momento houver um erro na chamada da API, os ajustes de pontos
# concluídos com sucesso serão salvos em um arquivo CSV para que você saiba quais estão completos e onde precisa retomar.
def assignPointAdjustments(point_adjustments):
assigned_point_adjustments = []
# Auto-limitação de taxa para não afetar outras integrações
sleep_time = 60 / max_requests_per_minute # Número de segundos para aguardar entre cada chamada para permanecer dentro do limite máximo de solicitações
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 pontos com base nos novos valores de pontos de gamificação. Por favor, veja {point_adjustment_link}.'
})
start = time.time() # Acompanhar o tempo de execução. Como as chamadas de API levam vários milissegundos, não conte esse tempo para o tempo de espera máximo de solicitações.
try:
r = requests.post(endpoint, headers=headers, data=payload)
except Exception as e:
# Em caso de falha, salve os ajustes atribuídos em um arquivo CSV para sabermos quais já estão completos
exportCSV(assigned_point_adjustments)
raise
# Se o máximo de solicitações for inferior a 300, não deveríamos atingir um limite de taxa. Se acaso atingirmos o limite de taxa, lidaremos com isso adequadamente.
while r.status_code == 429:
wait_time = r.json()["extras"]["wait_seconds"] + 1
print(f'Atingiu o limite de taxa. Aguardando {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:
# Em caso de falha, salve os ajustes atribuídos em um arquivo CSV para sabermos quais já estão completos
exportCSV(assigned_point_adjustments)
raise
if r.status_code != 200:
exportCSV(assigned_point_adjustments)
print(f'A última solicitação retornou um erro {r.status_code} com a seguinte mensagem de erro\n{r.text}')
print('Abortando ajustes e escrevendo os ajustes bem-sucedidos no arquivo')
raise Exception("Erro HTTP")
end = time.time()
if end - start < sleep_time:
# Aguardar apenas se o tempo da solicitação for menor que o tempo de espera máximo de solicitações
time.sleep(sleep_time - (end - start))
adjustment['external_gamification_point_id'] = r.json()["id"]
print(f'Atribuído {adjustment["difference"]} pontos a {adjustment["username"]}')
assigned_point_adjustments.append(adjustment)
return assigned_point_adjustments
limit = 1000 # Este é o limite 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)
Os passos para executar o ajuste de pontos
Agora que temos todas as peças no lugar, você precisará seguir estes passos para executar o processo de ajuste de pontos. É recomendado que você teste este processo em uma instância de teste do seu ambiente Discourse primeiro, para que possa resolver quaisquer problemas.
- Atualize as variáveis no script Python para os valores desejados.
- Altere os valores dos pontos nas configurações de Gamificação para os novos valores.
- Recalcule as pontuações para “Todo o período”.
- Execute o script Python e aguarde sua conclusão. Dependendo de quantos usuários há no seu sistema e qual é o seu limite de taxa, isso pode levar vários minutos para ser concluído. Tivemos 2.200 usuários que precisavam de um ajuste. Com um limite de taxa de 200 solicitações/minuto, levou cerca de 15 minutos para ser concluído.
- Após a conclusão, recalcule as pontuações para “Todo o período” novamente, apenas para garantir.
- Salve o arquivo CSV em um local seguro para seus registros, caso precise reverter qualquer alteração.
O recálculo dos pontos pode levar algumas horas para ser concluído. Você precisará ter paciência para ver os ajustes surtirem efeito. Uma vez que o recálculo esteja completo, você terá ajustado com sucesso os pontos dos seus usuários, e os pontos a partir de agora serão calculados com base nos novos valores de pontos.
Fazendo mais ajustes no futuro
Se a qualquer momento você precisar fazer ajustes nos valores dos pontos, pode seguir o mesmo processo. A única diferença é que você precisa definir a start_date como a end_date da última vez que executou o ajuste de pontos. Por exemplo, a primeira vez que executei este ajuste de pontos, defini a end_date para 2024-05-15. Isso garantiu que todos os pontos ganhos entre o início do meu fórum e 2024-05-15 fossem ajustados para preservar os valores antigos. Vamos supor que um ano passe e eu queira mudar os valores dos pontos novamente. Agora preciso definir a start_date para 2024-05-15 e a end_date para 2025-05-15. Isso garantirá que o ajuste de pontos se aplique apenas a esse período e não substitua nada da primeira vez que você executou o ajuste de pontos.

