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
5 changes: 3 additions & 2 deletions .github/workflows/assign-discord-roles.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ name: Assign Earned Discord Roles

on:
schedule:
# Daily, shortly after the 00:00 UTC MEE6 XP sync so fresh XP counts.
- cron: '30 0 * * *'
# Every 3 hours, shortly after the :37 Discord role sync. The 00:43 run
# also follows the daily 00:00 UTC MEE6 XP sync.
- cron: '43 */3 * * *'
workflow_dispatch:
inputs:
target:
Expand Down
4 changes: 2 additions & 2 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,8 +350,8 @@ backend/
- **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.
- **Admin**: superusers can start the same locked background assignment from the `DiscordEarnedRoleAssignment` changelist after confirming the operation.
- **Trigger**: `POST /api/v1/users/discord/assign-earned-roles/` (cron-protected, background thread + `DiscordRoleSyncLock` row `discord_earned_role_assign`), called every three hours at `:43` UTC by `.github/workflows/assign-discord-roles.yml` (after the `:37` Discord role sync; the `00:43` run also follows the daily `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. Staff can also run it from the earned-role assignment admin after being granted `social_connections.run_discord_earned_role_assignment` (directly or through a group).
- **Admin**: staff users with `social_connections.run_discord_earned_role_assignment` (granted directly or through a group) can start the same locked background assignment from the `DiscordEarnedRoleAssignment` changelist after confirming the operation; superusers have this permission automatically.
- **Ops**: the bot needs Manage Roles and its role above Synapse/Brain in the guild hierarchy.

### Database & Migrations
Expand Down
14 changes: 12 additions & 2 deletions backend/social_connections/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ class DiscordRoleSyncLockAdmin(admin.ModelAdmin):

@admin.register(DiscordEarnedRoleAssignment)
class DiscordEarnedRoleAssignmentAdmin(admin.ModelAdmin):
run_assignment_permission = 'social_connections.run_discord_earned_role_assignment'
change_list_template = 'admin/social_connections/discordearnedroleassignment/change_list.html'
list_select_related = ('connection__user',)
list_display = ('created_at', 'role_name', 'discord_username', 'connection', 'total_points', 'poap_count')
Expand Down Expand Up @@ -130,11 +131,14 @@ def get_urls(self):

def changelist_view(self, request, extra_context=None):
extra_context = extra_context or {}
extra_context['can_run_assignment'] = request.user.is_superuser
extra_context['can_run_assignment'] = self.has_run_assignment_permission(request)
return super().changelist_view(request, extra_context=extra_context)

def has_run_assignment_permission(self, request):
return request.user.has_perm(self.run_assignment_permission)

def run_assignment_view(self, request):
if not request.user.is_superuser:
if not self.has_run_assignment_permission(request):
raise PermissionDenied

changelist_url = reverse(
Expand Down Expand Up @@ -175,6 +179,12 @@ def run_assignment_view(self, request):
def has_add_permission(self, request):
return False

def has_view_permission(self, request, obj=None):
return (
super().has_view_permission(request, obj=obj)
or self.has_run_assignment_permission(request)
)

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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generated by Django 6.0.6 on 2026-07-15

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('social_connections', '0006_discordearnedroleassignment'),
]

operations = [
migrations.AlterModelOptions(
name='discordearnedroleassignment',
options={
'ordering': ['-created_at'],
'permissions': (
(
'run_discord_earned_role_assignment',
'Can run Discord earned role assignment',
),
),
},
),
]
6 changes: 6 additions & 0 deletions backend/social_connections/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ class DiscordEarnedRoleAssignment(BaseModel):
class Meta:
db_table = 'social_connections_discord_earned_role_assignment'
ordering = ['-created_at']
permissions = (
(
'run_discord_earned_role_assignment',
'Can run Discord earned role assignment',
),
)

def __str__(self):
return f"{self.discord_username or self.discord_user_id}: {self.role_name}"
Expand Down
32 changes: 31 additions & 1 deletion backend/social_connections/tests/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def test_post_reports_existing_run(self, mock_start):
self.assertContains(response, '12 seconds ago')
mock_start.assert_called_once_with()

def test_non_superuser_cannot_run_assignment(self):
def test_staff_with_view_permission_cannot_run_assignment(self):
staff = get_user_model().objects.create_user(
email='staff@test.com',
password='password',
Expand All @@ -69,7 +69,37 @@ def test_non_superuser_cannot_run_assignment(self):
self.client.force_login(staff)

changelist_response = self.client.get(self.changelist_url)
confirmation_response = self.client.get(self.run_url)
run_response = self.client.post(self.run_url)

self.assertNotContains(changelist_response, 'Run earned role assignment')
self.assertEqual(confirmation_response.status_code, 403)
self.assertEqual(run_response.status_code, 403)

@patch(
'social_connections.admin.start_earned_role_assignment',
return_value=(True, None),
)
def test_staff_with_run_permission_can_run_assignment(self, mock_start):
staff = get_user_model().objects.create_user(
email='role-manager@test.com',
password='password',
is_staff=True,
)
staff.user_permissions.add(
Permission.objects.get(codename='run_discord_earned_role_assignment')
)
self.client.force_login(staff)

admin_index_response = self.client.get(reverse('admin:index'))
changelist_response = self.client.get(self.changelist_url)
confirmation_response = self.client.get(self.run_url)
run_response = self.client.post(self.run_url, follow=True)

self.assertContains(admin_index_response, self.changelist_url)
self.assertEqual(changelist_response.status_code, 200)
self.assertContains(changelist_response, 'Run earned role assignment')
self.assertEqual(confirmation_response.status_code, 200)
self.assertRedirects(run_response, self.changelist_url)
self.assertContains(run_response, 'Earned Discord role assignment started.')
mock_start.assert_called_once_with()
8 changes: 4 additions & 4 deletions frontend/src/routes/BuilderJourney.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -39,27 +39,27 @@
};

const BRADBURY_NETWORK = {
chainId: '0x107D',
chainId: '0x107d',
chainName: 'GenLayer Bradbury',
nativeCurrency: { name: 'GEN', symbol: 'GEN', decimals: 18 },
rpcUrls: ['https://rpc-bradbury.genlayer.com'],
blockExplorerUrls: ['https://explorer-bradbury.genlayer.com'],
};

const ASIMOV_NETWORK = {
chainId: '0x107D',
chainId: '0x107d',
chainName: 'GenLayer Asimov',
nativeCurrency: { name: 'GEN', symbol: 'GEN', decimals: 18 },
rpcUrls: ['https://rpc-asimov.genlayer.com'],
blockExplorerUrls: ['https://explorer-asimov.genlayer.com'],
};

const STUDIO_NETWORK = {
chainId: '0xF22F',
chainId: '0xf22f',
chainName: 'GenLayer Studio',
nativeCurrency: { name: 'GEN', symbol: 'GEN', decimals: 18 },
rpcUrls: ['https://studio.genlayer.com/api'],
blockExplorerUrls: [],
blockExplorerUrls: ['https://explorer-studio.genlayer.com'],
};

const NETWORKS = [
Expand Down
Loading