Skip to content

Commit e78e93f

Browse files
authored
Merge pull request #202 from PotLock/feature/grantpicks-round-application-sync
Added grantpicks list sync endpoints
2 parents 02d36ea + 629360c commit e78e93f

File tree

2 files changed

+277
-5
lines changed

2 files changed

+277
-5
lines changed

api/urls.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@
6060
RoundDepositsSyncAPI,
6161
RoundVotesSyncAPI,
6262
RoundPayoutsSyncAPI,
63+
StellarListSyncAPI,
64+
StellarListRegistrationsSyncAPI,
65+
StellarSingleRegistrationSyncAPI,
66+
StellarListDeleteSyncAPI,
6367
)
6468
from lists.api import (
6569
ListDetailAPI,
@@ -275,19 +279,36 @@
275279
name="mpdao_voter_detail",
276280
),
277281
# sync endpoints (for on-demand data fetching from blockchain)
282+
# NEAR list sync endpoints (kept for NEAR/potlock frontend)
278283
path(
279-
"v1/lists/<int:list_id>/sync",
284+
"v1/near/lists/<int:list_id>/sync",
280285
ListSyncAPI.as_view(),
286+
name="near_list_sync_api",
287+
),
288+
path(
289+
"v1/near/lists/<int:list_id>/registrations/sync",
290+
ListRegistrationsSyncAPI.as_view(),
291+
name="near_list_registrations_sync_api",
292+
),
293+
path(
294+
"v1/near/lists/<int:list_id>/registrations/<str:registrant_id>/sync",
295+
SingleRegistrationSyncAPI.as_view(),
296+
name="near_single_registration_sync_api",
297+
),
298+
# Stellar list sync endpoints (grantpicks frontend)
299+
path(
300+
"v1/lists/<int:list_id>/sync",
301+
StellarListSyncAPI.as_view(),
281302
name="list_sync_api",
282303
),
283304
path(
284305
"v1/lists/<int:list_id>/registrations/sync",
285-
ListRegistrationsSyncAPI.as_view(),
306+
StellarListRegistrationsSyncAPI.as_view(),
286307
name="list_registrations_sync_api",
287308
),
288309
path(
289310
"v1/lists/<int:list_id>/registrations/<str:registrant_id>/sync",
290-
SingleRegistrationSyncAPI.as_view(),
311+
StellarSingleRegistrationSyncAPI.as_view(),
291312
name="single_registration_sync_api",
292313
),
293314
# pot sync endpoints
@@ -358,8 +379,13 @@
358379
name="pot_challenges_sync_api",
359380
),
360381
path(
361-
"v1/lists/<int:list_id>/delete/sync",
382+
"v1/near/lists/<int:list_id>/delete/sync",
362383
ListDeleteSyncAPI.as_view(),
384+
name="near_list_delete_sync_api",
385+
),
386+
path(
387+
"v1/lists/<int:list_id>/delete/sync",
388+
StellarListDeleteSyncAPI.as_view(),
363389
name="list_delete_sync_api",
364390
),
365391
path(

grantpicks/sync.py

Lines changed: 247 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""
22
Sync endpoints for grantpicks - fetch data from Stellar RPC and store in database.
33
4-
Called by frontend after user performs on-chain actions (create/update project, round, application).
4+
Called by frontend after user performs on-chain actions (create/update project, round, application, list).
55
66
Endpoints:
77
POST /api/v1/projects/{project_id}/sync - Sync single project from chain
@@ -14,6 +14,10 @@
1414
POST /api/v1/rounds/{round_id}/deposits/sync - Sync deposits for a round from chain
1515
POST /api/v1/rounds/{round_id}/votes/sync - Sync votes for a round from chain
1616
POST /api/v1/rounds/{round_id}/payouts/sync - Sync payouts for a round from chain
17+
POST /api/v1/lists/{list_id}/sync - Sync single list from chain
18+
POST /api/v1/lists/{list_id}/registrations/sync - Sync registrations for a list from chain
19+
POST /api/v1/lists/{list_id}/registrations/{registrant_id}/sync - Sync single registration from chain
20+
POST /api/v1/lists/{list_id}/delete/sync - Delete list from DB after on-chain deletion
1721
"""
1822
import json
1923
import logging
@@ -24,6 +28,9 @@
2428
from rest_framework.response import Response
2529
from rest_framework.views import APIView
2630

31+
from accounts.models import Account
32+
from lists.models import List, ListRegistration
33+
2734
from indexer_app.tasks import address_to_string
2835
from indexer_app.utils import (
2936
create_or_update_round,
@@ -50,6 +57,11 @@
5057
"dev": "CD6X5JVK6ITAZGOMIUBVJUHFMK34YW2ZEWQ2BDLV6XFRFGNV56A4L3RC",
5158
}.get(settings.ENVIRONMENT, "")
5259

60+
_STELLAR_LISTS_CONTRACT = {
61+
"testnet": "CCLXSELRRF67M3K5JJYNT6HRTJN26JDJKZYKR5QTZAEZU2TSCF6OGFZT",
62+
"dev": "CAIYXP5CNFB5WUBAWEPBZHIKYZGP3IEFXILFMDUT37FZBKNGFJGNJPNT",
63+
}.get(settings.ENVIRONMENT, "")
64+
5365

5466
def fetch_from_stellar_rpc(contract_id, function_name, parameters):
5567
"""Call a Stellar contract view method via simulate_transaction."""
@@ -365,3 +377,237 @@ def post(self, request, round_id: int):
365377
synced += 1
366378

367379
return Response({"success": True, "synced": synced, "total": len(data)})
380+
381+
382+
class StellarListSyncAPI(APIView):
383+
"""POST /api/v1/lists/{list_id}/sync - Sync a single list from Stellar chain."""
384+
385+
def post(self, request, list_id: int):
386+
try:
387+
data = fetch_from_stellar_rpc(
388+
_STELLAR_LISTS_CONTRACT,
389+
"get_list",
390+
[stellar_sdk.scval.to_uint128(list_id)],
391+
)
392+
if not data:
393+
return Response({"error": "List not found on chain"}, status=404)
394+
395+
# Map Stellar field names to DB fields
396+
owner_id = data.get("owner", "")
397+
Account.objects.get_or_create(id=owner_id)
398+
399+
# default_registration_status comes as array like ["Pending"] from Stellar
400+
default_status = data.get("default_registration_status", "Pending")
401+
if isinstance(default_status, list):
402+
default_status = default_status[0] if default_status else "Pending"
403+
404+
created_at = datetime.fromtimestamp(data.get("created_ms", 0) / 1000) if data.get("created_ms") else datetime.now()
405+
updated_at = datetime.fromtimestamp(data.get("updated_ms", 0) / 1000) if data.get("updated_ms") else datetime.now()
406+
407+
existing_list = List.objects.filter(on_chain_id=int(list_id)).first()
408+
409+
if existing_list:
410+
existing_list.name = data.get("name", "")
411+
existing_list.description = data.get("description", "")
412+
existing_list.cover_image_url = data.get("cover_img_url") or data.get("cover_image_url")
413+
existing_list.admin_only_registrations = data.get("admin_only_registrations", False)
414+
existing_list.default_registration_status = default_status
415+
existing_list.updated_at = updated_at
416+
existing_list.save()
417+
418+
existing_list.admins.clear()
419+
for admin_id in data.get("admins", []):
420+
admin, _ = Account.objects.get_or_create(id=admin_id)
421+
existing_list.admins.add(admin)
422+
423+
return Response({"success": True, "message": "List updated", "on_chain_id": list_id})
424+
425+
list_obj = List.objects.create(
426+
on_chain_id=data.get("id", list_id),
427+
owner_id=owner_id,
428+
name=data.get("name", ""),
429+
description=data.get("description", ""),
430+
cover_image_url=data.get("cover_img_url") or data.get("cover_image_url"),
431+
admin_only_registrations=data.get("admin_only_registrations", False),
432+
default_registration_status=default_status,
433+
created_at=created_at,
434+
updated_at=updated_at,
435+
)
436+
437+
for admin_id in data.get("admins", []):
438+
admin, _ = Account.objects.get_or_create(id=admin_id)
439+
list_obj.admins.add(admin)
440+
441+
return Response({"success": True, "message": "List created", "on_chain_id": list_obj.on_chain_id})
442+
443+
except Exception as e:
444+
logger.error(f"Error syncing list {list_id}: {e}")
445+
return Response({"error": str(e)}, status=502)
446+
447+
448+
class StellarListRegistrationsSyncAPI(APIView):
449+
"""POST /api/v1/lists/{list_id}/registrations/sync - Sync registrations for a list from Stellar chain."""
450+
451+
def post(self, request, list_id: int):
452+
try:
453+
# Ensure list exists in DB
454+
try:
455+
list_obj = List.objects.get(on_chain_id=int(list_id))
456+
except List.DoesNotExist:
457+
sync = StellarListSyncAPI()
458+
resp = sync.post(request, list_id)
459+
if resp.status_code != 200:
460+
return Response({"error": "List not found"}, status=404)
461+
list_obj = List.objects.get(on_chain_id=int(list_id))
462+
463+
# Fetch all registrations (no status filter — pass None)
464+
registrations = fetch_from_stellar_rpc(
465+
_STELLAR_LISTS_CONTRACT,
466+
"get_registrations_for_list",
467+
[
468+
stellar_sdk.scval.to_uint128(list_id),
469+
stellar_sdk.scval.to_void(),
470+
stellar_sdk.scval.to_uint64(0),
471+
stellar_sdk.scval.to_uint64(100),
472+
],
473+
)
474+
475+
if not registrations:
476+
registrations = []
477+
478+
synced = 0
479+
for reg in registrations:
480+
registrant_id = reg.get("registrant_id", "")
481+
registered_by = reg.get("registered_by", registrant_id)
482+
Account.objects.get_or_create(id=registrant_id)
483+
Account.objects.get_or_create(id=registered_by)
484+
485+
status = reg.get("status", "Pending")
486+
if isinstance(status, list):
487+
status = status[0] if status else "Pending"
488+
489+
submitted_at = datetime.fromtimestamp(reg.get("submitted_ms", 0) / 1000) if reg.get("submitted_ms") else datetime.now()
490+
updated_at = datetime.fromtimestamp(reg.get("updated_ms", 0) / 1000) if reg.get("updated_ms") else datetime.now()
491+
492+
ListRegistration.objects.update_or_create(
493+
list=list_obj,
494+
registrant_id=registrant_id,
495+
defaults={
496+
"registered_by_id": registered_by,
497+
"status": status,
498+
"submitted_at": submitted_at,
499+
"updated_at": updated_at,
500+
"admin_notes": reg.get("admin_notes"),
501+
"registrant_notes": reg.get("registrant_notes"),
502+
}
503+
)
504+
synced += 1
505+
506+
return Response({"success": True, "message": f"Synced {synced} registrations", "synced_count": synced})
507+
508+
except Exception as e:
509+
logger.error(f"Error syncing registrations for list {list_id}: {e}")
510+
return Response({"error": str(e)}, status=502)
511+
512+
513+
class StellarSingleRegistrationSyncAPI(APIView):
514+
"""POST /api/v1/lists/{list_id}/registrations/{registrant_id}/sync - Sync single registration from Stellar chain."""
515+
516+
def post(self, request, list_id: int, registrant_id: str):
517+
try:
518+
# Ensure list exists in DB
519+
try:
520+
list_obj = List.objects.get(on_chain_id=int(list_id))
521+
except List.DoesNotExist:
522+
sync = StellarListSyncAPI()
523+
resp = sync.post(request, list_id)
524+
if resp.status_code != 200:
525+
return Response({"error": "List not found"}, status=404)
526+
list_obj = List.objects.get(on_chain_id=int(list_id))
527+
528+
# Fetch all registrations and find the one we need
529+
registrations = fetch_from_stellar_rpc(
530+
_STELLAR_LISTS_CONTRACT,
531+
"get_registrations_for_list",
532+
[
533+
stellar_sdk.scval.to_uint128(list_id),
534+
stellar_sdk.scval.to_void(),
535+
stellar_sdk.scval.to_uint64(0),
536+
stellar_sdk.scval.to_uint64(100),
537+
],
538+
)
539+
540+
if not registrations:
541+
return Response({"error": "No registrations found for list"}, status=404)
542+
543+
reg = next((r for r in registrations if r.get("registrant_id") == registrant_id), None)
544+
if not reg:
545+
return Response({"error": "Registration not found"}, status=404)
546+
547+
Account.objects.get_or_create(id=reg["registrant_id"])
548+
registered_by = reg.get("registered_by", reg["registrant_id"])
549+
Account.objects.get_or_create(id=registered_by)
550+
551+
status = reg.get("status", "Pending")
552+
if isinstance(status, list):
553+
status = status[0] if status else "Pending"
554+
555+
submitted_at = datetime.fromtimestamp(reg.get("submitted_ms", 0) / 1000) if reg.get("submitted_ms") else datetime.now()
556+
updated_at = datetime.fromtimestamp(reg.get("updated_ms", 0) / 1000) if reg.get("updated_ms") else datetime.now()
557+
558+
registration, created = ListRegistration.objects.update_or_create(
559+
list=list_obj,
560+
registrant_id=reg["registrant_id"],
561+
defaults={
562+
"registered_by_id": registered_by,
563+
"status": status,
564+
"submitted_at": submitted_at,
565+
"updated_at": updated_at,
566+
"admin_notes": reg.get("admin_notes"),
567+
"registrant_notes": reg.get("registrant_notes"),
568+
}
569+
)
570+
571+
return Response({"success": True, "message": "Registration synced", "registrant_id": registrant_id, "status": registration.status})
572+
573+
except Exception as e:
574+
logger.error(f"Error syncing registration: {e}")
575+
return Response({"error": str(e)}, status=502)
576+
577+
578+
class StellarListDeleteSyncAPI(APIView):
579+
"""POST /api/v1/lists/{list_id}/delete/sync - Delete list from DB after on-chain deletion."""
580+
581+
def post(self, request, list_id: int):
582+
try:
583+
# Verify list no longer exists on chain
584+
data = fetch_from_stellar_rpc(
585+
_STELLAR_LISTS_CONTRACT,
586+
"get_list",
587+
[stellar_sdk.scval.to_uint128(list_id)],
588+
)
589+
590+
if data:
591+
return Response({"error": "List still exists on chain — not deleted"}, status=400)
592+
593+
# List doesn't exist on chain — safe to delete from DB
594+
deleted_count, _ = List.objects.filter(on_chain_id=int(list_id)).delete()
595+
596+
if deleted_count > 0:
597+
logger.info(f"List {list_id} deleted from DB (verified not on chain)")
598+
return Response({"success": True, "message": "List deleted", "on_chain_id": list_id})
599+
600+
return Response({"error": "List not found in database"}, status=404)
601+
602+
except Exception as e:
603+
# If RPC call fails (e.g. contract panics with "List does not exist"), that confirms deletion
604+
error_str = str(e)
605+
if "does not exist" in error_str or "not found" in error_str.lower():
606+
deleted_count, _ = List.objects.filter(on_chain_id=int(list_id)).delete()
607+
if deleted_count > 0:
608+
logger.info(f"List {list_id} deleted from DB (RPC confirmed not on chain)")
609+
return Response({"success": True, "message": "List deleted", "on_chain_id": list_id})
610+
return Response({"error": "List not found in database"}, status=404)
611+
612+
logger.error(f"Error syncing list deletion {list_id}: {e}")
613+
return Response({"error": str(e)}, status=502)

0 commit comments

Comments
 (0)