Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ backend/

### Earned Discord Roles (social_connections)
- **Logic**: `social_connections/earned_roles.py:assign_earned_community_roles(dry_run=False)` assigns the Synapse (14,000 effective CP + 8 POAPs) and Brain (80,000 effective CP + 16 POAPs + Neurocreative role held) Discord roles to qualifying users. Add-only: never removes roles; each role's threshold is evaluated independently. CP comes from `community_xp.utils.build_effective_community_scores_queryset` (MEE6 baseline + pending contribution and community social-task points), POAPs from `PoapClaim` counts, held roles from `DiscordConnection.current_roles` (the 15-min sync is the reconciler; a successful assignment also updates the local cache). Aborts the run on 429/401/403 (rate limit, missing Manage Roles, hierarchy); other per-user failures log and continue. No-op unless all three `DISCORD_*_ROLE_ID` env vars are set.
- **Audit**: `DiscordEarnedRoleAssignment` stores each successful automatic grant with the Discord member, role, CP/POAP snapshot, and creation time; records are read-only in admin.
- **Service**: `DiscordRoleSyncService.add_member_role(discord_user_id, role_id)` PUTs to Discord; 404 (member left) returns False.
- **Trigger**: `POST /api/v1/users/discord/assign-earned-roles/` (cron-protected, background thread + `DiscordRoleSyncLock` row `discord_earned_role_assign`), called daily at 00:30 UTC by `.github/workflows/assign-discord-roles.yml` (after the 00:00 MEE6 XP sync). Manual/backfill: `python manage.py assign_earned_discord_roles [--dry-run]`; dry run prints the would-assign list for review.
- **Ops**: the bot needs Manage Roles and its role above Synapse/Brain in the guild hierarchy.
Expand Down
29 changes: 29 additions & 0 deletions backend/social_connections/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from .models import (
DiscordConnection,
DiscordEarnedRoleAssignment,
DiscordRole,
DiscordRoleSyncLock,
GitHubConnection,
Expand Down Expand Up @@ -94,6 +95,34 @@ class DiscordRoleSyncLockAdmin(admin.ModelAdmin):
readonly_fields = ('name', 'owner_token', 'acquired_at', 'heartbeat_at', 'released_at')


@admin.register(DiscordEarnedRoleAssignment)
class DiscordEarnedRoleAssignmentAdmin(admin.ModelAdmin):
list_select_related = ('connection__user',)
list_display = ('created_at', 'role_name', 'discord_username', 'connection', 'total_points', 'poap_count')
list_filter = ('role_name',)
search_fields = ('discord_username', 'discord_user_id', 'connection__user__email')
date_hierarchy = 'created_at'
readonly_fields = (
'connection',
'discord_user_id',
'discord_username',
'role_id',
'role_name',
'total_points',
'poap_count',
'created_at',
)

def has_add_permission(self, request):
return False

def has_change_permission(self, request, obj=None):
return False

def has_delete_permission(self, request, obj=None):
return False


@admin.register(PendingOAuthState)
class PendingOAuthStateAdmin(admin.ModelAdmin):
list_display = ('platform', 'user', 'created_at', 'consumed_at')
Expand Down
16 changes: 13 additions & 3 deletions backend/social_connections/discord_roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import time
from dataclasses import dataclass
from datetime import timedelta, timezone as datetime_timezone
from urllib.parse import quote

import requests
from django.conf import settings
Expand Down Expand Up @@ -77,14 +78,16 @@ def _parse_retry_after(self, response):
except (TypeError, ValueError):
return None

def _request(self, method, path, trace_name, retry_once=True):
def _request(self, method, path, trace_name, retry_once=True, audit_log_reason=None):
self._ensure_configured()

url = f"{self.api_base_url}{path}"
headers = {
'Authorization': f'Bot {self.bot_token}',
'Accept': 'application/json',
}
if audit_log_reason:
headers['X-Audit-Log-Reason'] = quote(audit_log_reason, safe='')

try:
with trace_external('discord', trace_name):
Expand All @@ -109,7 +112,13 @@ def _request(self, method, path, trace_name, retry_once=True):
retry_after = self._parse_retry_after(response)
if retry_after is not None and retry_after <= 2:
time.sleep(retry_after)
return self._request(method, path, trace_name, retry_once=False)
return self._request(
method,
path,
trace_name,
retry_once=False,
audit_log_reason=audit_log_reason,
)
raise DiscordRoleSyncUnavailable(
'Discord rate limit exceeded',
status_code=429,
Expand Down Expand Up @@ -285,12 +294,13 @@ def sync_member_roles(self, connection, sync_catalog=True):
connection.current_roles.set(roles)
return MemberRoleSyncResult(connection=connection, is_member=True)

def add_member_role(self, discord_user_id, role_id):
def add_member_role(self, discord_user_id, role_id, audit_log_reason=None):
"""Assign one guild role to a member. Returns False if the member left."""
response = self._request(
'PUT',
f'/guilds/{self.guild_id}/members/{discord_user_id}/roles/{role_id}',
'add_member_role',
audit_log_reason=audit_log_reason,
)

if response.status_code == 404:
Expand Down
25 changes: 22 additions & 3 deletions backend/social_connections/earned_roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

from django.conf import settings
from django.db import transaction
from django.db.models import Count

from tally.middleware.logging_utils import get_app_logger
Expand All @@ -14,7 +15,7 @@
DiscordRoleSyncService,
DiscordRoleSyncUnavailable,
)
from .models import DiscordConnection
from .models import DiscordConnection, DiscordEarnedRoleAssignment

logger = get_app_logger('discord_roles')

Expand Down Expand Up @@ -120,7 +121,15 @@ def assign_earned_community_roles(dry_run=False, service=None):
for label, role_id in _wanted_roles(role_ids, total_points, poap_count, held):
if not dry_run:
try:
assigned = service.add_member_role(connection.platform_user_id, role_id)
audit_reason = (
f'Automatic earned role assignment: {label} '
f'({total_points} CP, {poap_count} POAPs)'
)
assigned = service.add_member_role(
connection.platform_user_id,
role_id,
audit_log_reason=audit_reason,
)
except DiscordRoleSyncConfigurationError as exc:
logger.warning("Earned role assignment aborted: %s", exc)
stats['errors'] += 1
Expand All @@ -144,7 +153,17 @@ def assign_earned_community_roles(dry_run=False, service=None):
stats['skipped_not_member'] += 1
continue

connection.current_roles.add(*service._get_or_create_missing_roles([role_id]))
with transaction.atomic():
connection.current_roles.add(*service._get_or_create_missing_roles([role_id]))
DiscordEarnedRoleAssignment.objects.create(
connection=connection,
discord_user_id=connection.platform_user_id,
discord_username=connection.platform_username,
role_id=role_id,
role_name=label,
total_points=total_points,
poap_count=poap_count,
)
logger.info(
"Assigned %s role to user %s (discord %s): %s CP, %s POAPs",
label,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Generated by Django 6.0.6 on 2026-07-13 19:11

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('social_connections', '0005_pendingoauthstate_session_key'),
]

operations = [
migrations.CreateModel(
name='DiscordEarnedRoleAssignment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('discord_user_id', models.CharField(db_index=True, max_length=100)),
('discord_username', models.CharField(blank=True, max_length=100)),
('role_id', models.CharField(db_index=True, max_length=100)),
('role_name', models.CharField(max_length=100)),
('total_points', models.PositiveIntegerField()),
('poap_count', models.PositiveIntegerField()),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('connection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='earned_role_assignments', to='social_connections.discordconnection')),
],
options={
'db_table': 'social_connections_discord_earned_role_assignment',
'ordering': ['-created_at'],
},
),
]
28 changes: 28 additions & 0 deletions backend/social_connections/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from django.db import models
from django.utils import timezone

from utils.models import BaseModel

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -143,6 +145,32 @@ def __str__(self):
return f"DiscordRoleSyncLock({self.name}, acquired={self.acquired_at})"


class DiscordEarnedRoleAssignment(BaseModel):
"""Append-only record of an earned Discord role granted by the portal."""

connection = models.ForeignKey(
DiscordConnection,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='earned_role_assignments',
)
discord_user_id = models.CharField(max_length=100, db_index=True)
discord_username = models.CharField(max_length=100, blank=True)
role_id = models.CharField(max_length=100, db_index=True)
role_name = models.CharField(max_length=100)
total_points = models.PositiveIntegerField()
poap_count = models.PositiveIntegerField()
created_at = models.DateTimeField(auto_now_add=True, db_index=True)

class Meta:
db_table = 'social_connections_discord_earned_role_assignment'
ordering = ['-created_at']

def __str__(self):
return f"{self.discord_username or self.discord_user_id}: {self.role_name}"


class PendingOAuthState(models.Model):
"""Short-lived OAuth state data stored server-side for multi-worker safety."""

Expand Down
26 changes: 25 additions & 1 deletion backend/social_connections/tests/test_earned_roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@
SYNAPSE_POAPS,
assign_earned_community_roles,
)
from social_connections.models import DiscordConnection, DiscordRole
from social_connections.models import (
DiscordConnection,
DiscordEarnedRoleAssignment,
DiscordRole,
)
from social_tasks.models import SocialTask, SocialTaskCompletion
from users.models import User

Expand Down Expand Up @@ -62,6 +66,17 @@ def test_204_assigns(self, mock_request):
'https://discord.com/api/v10/guilds/guild-1/members/user-1/roles/role-1',
)

@patch('social_connections.discord_roles.requests.request')
def test_sends_audit_log_reason(self, mock_request):
mock_request.return_value = mock_response(204)

self.service.add_member_role('user-1', 'role-1', audit_log_reason='Earned role: synapse')

self.assertEqual(
mock_request.call_args.kwargs['headers']['X-Audit-Log-Reason'],
'Earned%20role%3A%20synapse',
)

@patch('social_connections.discord_roles.requests.request')
def test_404_returns_false(self, mock_request):
mock_request.return_value = mock_response(404)
Expand Down Expand Up @@ -148,6 +163,14 @@ def test_assigns_synapse_at_thresholds(self, mock_request):
self.assertIn(f'discord-{user.id}', args_url)
self.assertTrue(connection.current_roles.filter(role_id='role-synapse').exists())
self.assertEqual(stats['assignments'][0]['role'], 'synapse')
assignment = DiscordEarnedRoleAssignment.objects.get()
self.assertEqual(assignment.connection, connection)
self.assertEqual(assignment.discord_user_id, f'discord-{user.id}')
self.assertEqual(assignment.role_id, 'role-synapse')
self.assertEqual(assignment.role_name, 'synapse')
self.assertEqual(assignment.total_points, SYNAPSE_CP)
self.assertEqual(assignment.poap_count, SYNAPSE_POAPS)
self.assertIsNotNone(assignment.created_at)

@patch('social_connections.discord_roles.requests.request')
def test_pending_social_task_points_count_toward_synapse(self, mock_request):
Expand Down Expand Up @@ -265,6 +288,7 @@ def test_dry_run_makes_no_requests(self, mock_request):
self.assertEqual(len(stats['assignments']), 1)
mock_request.assert_not_called()
self.assertFalse(connection.current_roles.filter(role_id='role-synapse').exists())
self.assertFalse(DiscordEarnedRoleAssignment.objects.exists())

@patch('social_connections.discord_roles.requests.request')
def test_unconfigured_role_ids_noop(self, mock_request):
Expand Down
Loading