From ffc7efb0566f60262369865e0e7512e115eba805 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Wed, 15 Jul 2026 10:50:03 -0400 Subject: [PATCH 1/3] Fix wallet networks and delegate Discord role assignment --- backend/CLAUDE.md | 4 +-- backend/social_connections/admin.py | 14 ++++++-- ...ter_discordearnedroleassignment_options.py | 25 +++++++++++++++ backend/social_connections/models.py | 6 ++++ .../social_connections/tests/test_admin.py | 32 ++++++++++++++++++- frontend/src/routes/BuilderJourney.svelte | 8 ++--- 6 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 backend/social_connections/migrations/0007_alter_discordearnedroleassignment_options.py diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index c4e77a14..45d250f4 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -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 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. 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 diff --git a/backend/social_connections/admin.py b/backend/social_connections/admin.py index 591d5500..517e558b 100644 --- a/backend/social_connections/admin.py +++ b/backend/social_connections/admin.py @@ -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') @@ -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( @@ -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 diff --git a/backend/social_connections/migrations/0007_alter_discordearnedroleassignment_options.py b/backend/social_connections/migrations/0007_alter_discordearnedroleassignment_options.py new file mode 100644 index 00000000..6268858c --- /dev/null +++ b/backend/social_connections/migrations/0007_alter_discordearnedroleassignment_options.py @@ -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', + ), + ], + }, + ), + ] diff --git a/backend/social_connections/models.py b/backend/social_connections/models.py index c070d974..4f5750db 100644 --- a/backend/social_connections/models.py +++ b/backend/social_connections/models.py @@ -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}" diff --git a/backend/social_connections/tests/test_admin.py b/backend/social_connections/tests/test_admin.py index 370cabbf..fd0a36ea 100644 --- a/backend/social_connections/tests/test_admin.py +++ b/backend/social_connections/tests/test_admin.py @@ -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', @@ -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() diff --git a/frontend/src/routes/BuilderJourney.svelte b/frontend/src/routes/BuilderJourney.svelte index e7926443..dcfe2c86 100644 --- a/frontend/src/routes/BuilderJourney.svelte +++ b/frontend/src/routes/BuilderJourney.svelte @@ -39,7 +39,7 @@ }; const BRADBURY_NETWORK = { - chainId: '0x107D', + chainId: '0x107d', chainName: 'GenLayer Bradbury', nativeCurrency: { name: 'GEN', symbol: 'GEN', decimals: 18 }, rpcUrls: ['https://rpc-bradbury.genlayer.com'], @@ -47,7 +47,7 @@ }; const ASIMOV_NETWORK = { - chainId: '0x107D', + chainId: '0x107d', chainName: 'GenLayer Asimov', nativeCurrency: { name: 'GEN', symbol: 'GEN', decimals: 18 }, rpcUrls: ['https://rpc-asimov.genlayer.com'], @@ -55,11 +55,11 @@ }; 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 = [ From c84ff218002769738af9c19ab644211cab7f992c Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Wed, 15 Jul 2026 10:59:42 -0400 Subject: [PATCH 2/3] Use immutable Discord permission metadata --- .../0007_alter_discordearnedroleassignment_options.py | 4 ++-- backend/social_connections/models.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/social_connections/migrations/0007_alter_discordearnedroleassignment_options.py b/backend/social_connections/migrations/0007_alter_discordearnedroleassignment_options.py index 6268858c..28ec8626 100644 --- a/backend/social_connections/migrations/0007_alter_discordearnedroleassignment_options.py +++ b/backend/social_connections/migrations/0007_alter_discordearnedroleassignment_options.py @@ -14,12 +14,12 @@ class Migration(migrations.Migration): name='discordearnedroleassignment', options={ 'ordering': ['-created_at'], - 'permissions': [ + 'permissions': ( ( 'run_discord_earned_role_assignment', 'Can run Discord earned role assignment', ), - ], + ), }, ), ] diff --git a/backend/social_connections/models.py b/backend/social_connections/models.py index 4f5750db..43f61266 100644 --- a/backend/social_connections/models.py +++ b/backend/social_connections/models.py @@ -166,12 +166,12 @@ class DiscordEarnedRoleAssignment(BaseModel): class Meta: db_table = 'social_connections_discord_earned_role_assignment' ordering = ['-created_at'] - permissions = [ + 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}" From 364676b05a8fe6dbfa7fdb1aebe97845f4409746 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Wed, 15 Jul 2026 11:10:36 -0400 Subject: [PATCH 3/3] Run earned role assignment every three hours --- .github/workflows/assign-discord-roles.yml | 5 +++-- backend/CLAUDE.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/assign-discord-roles.yml b/.github/workflows/assign-discord-roles.yml index 0881c998..fc68d517 100644 --- a/.github/workflows/assign-discord-roles.yml +++ b/.github/workflows/assign-discord-roles.yml @@ -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: diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 45d250f4..b09eb4f5 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -350,7 +350,7 @@ 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. 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). +- **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.