Skip to content

Commit 1efbf67

Browse files
authored
Merge pull request #199 from PotLock/feature/lists-sync-prod
Added list deletion and upvote/remove upvote sync endpoints
2 parents 1121bb0 + de462be commit 1efbf67

File tree

2 files changed

+288
-2
lines changed

2 files changed

+288
-2
lines changed

api/sync.py

Lines changed: 270 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
POST /api/v1/lists/{list_id}/sync - Sync single list
1010
POST /api/v1/lists/{list_id}/registrations/sync - Sync all registrations
1111
POST /api/v1/lists/{list_id}/registrations/{registrant_id}/sync - Sync single registration
12+
POST /api/v1/lists/{list_id}/delete/sync - Sync list deletion via tx_hash
13+
POST /api/v1/lists/{list_id}/upvote/sync - Sync list upvote via tx_hash
14+
POST /api/v1/lists/{list_id}/remove-upvote/sync - Sync list remove-upvote via tx_hash
1215
1316
Accounts:
1417
POST /api/v1/accounts/{account_id}/sync - Sync account profile and recalculate stats
@@ -20,13 +23,14 @@
2023

2124
import requests
2225
from django.conf import settings
23-
from drf_spectacular.utils import OpenApiExample, OpenApiResponse, extend_schema
26+
from drf_spectacular.utils import OpenApiExample, OpenApiParameter, OpenApiResponse, extend_schema
2427
from rest_framework.response import Response
2528
from rest_framework.views import APIView
2629

2730
from accounts.models import Account
31+
from activities.models import Activity
2832
from donations.models import Donation
29-
from lists.models import List, ListRegistration
33+
from lists.models import List, ListRegistration, ListUpvote
3034
from pots.models import Pot, PotApplication, PotApplicationReview, PotFactory, PotPayout, PotPayoutChallenge, PotPayoutChallengeAdminResponse
3135
from tokens.models import Token
3236

@@ -119,6 +123,58 @@ def fetch_from_rpc(method_name: str, args: dict = None, contract_id: str = None,
119123
raise Exception(f"All RPC endpoints failed. Last error: {last_error}")
120124

121125

126+
def fetch_tx_result(tx_hash: str, sender_id: str):
127+
"""
128+
Fetch transaction result from NEAR RPC.
129+
Returns the parsed result from the transaction execution.
130+
"""
131+
rpc_url = (
132+
"https://test.rpc.fastnear.com"
133+
if settings.ENVIRONMENT == "testnet"
134+
else "https://free.rpc.fastnear.com"
135+
)
136+
137+
payload = {
138+
"jsonrpc": "2.0",
139+
"id": "dontcare",
140+
"method": "tx",
141+
"params": {
142+
"tx_hash": tx_hash,
143+
"sender_account_id": sender_id,
144+
"wait_until": "EXECUTED_OPTIMISTIC",
145+
},
146+
}
147+
148+
response = requests.post(rpc_url, json=payload, timeout=30)
149+
result = response.json()
150+
151+
if "error" in result:
152+
raise Exception(f"RPC error fetching tx: {result['error']}")
153+
154+
return result.get("result")
155+
156+
157+
def parse_events_from_tx(tx_result: dict, event_name: str) -> list[dict]:
158+
"""Parse EVENT_JSON logs from transaction receipts matching a specific event name."""
159+
events = []
160+
receipts_outcome = tx_result.get("receipts_outcome", [])
161+
162+
for outcome in receipts_outcome:
163+
logs = outcome.get("outcome", {}).get("logs", [])
164+
for log in logs:
165+
if not log.startswith("EVENT_JSON:"):
166+
continue
167+
try:
168+
parsed = json.loads(log[len("EVENT_JSON:"):])
169+
if parsed.get("event") == event_name:
170+
for data_item in parsed.get("data", []):
171+
events.append(data_item)
172+
except (json.JSONDecodeError, KeyError):
173+
continue
174+
175+
return events
176+
177+
122178
class ListSyncAPI(APIView):
123179
"""
124180
Sync a list from blockchain to database.
@@ -843,6 +899,218 @@ def post(self, request, pot_id: str):
843899
return Response({"error": str(e)}, status=502)
844900

845901

902+
class ListDeleteSyncAPI(APIView):
903+
"""
904+
Sync a list deletion from blockchain via tx_hash.
905+
906+
Called by frontend after user deletes a list on-chain.
907+
Fetches the transaction, parses the delete_list EVENT_JSON, and deletes from DB.
908+
"""
909+
910+
@extend_schema(
911+
summary="Sync list deletion from blockchain via tx_hash",
912+
parameters=[
913+
OpenApiParameter(name="tx_hash", required=True, type=str),
914+
OpenApiParameter(name="sender_id", required=True, type=str),
915+
],
916+
responses={
917+
200: OpenApiResponse(description="List deleted from DB"),
918+
400: OpenApiResponse(description="Missing parameters or no delete event found"),
919+
404: OpenApiResponse(description="List not found in DB"),
920+
502: OpenApiResponse(description="RPC failed"),
921+
},
922+
)
923+
def post(self, request, list_id: int):
924+
try:
925+
tx_hash = request.data.get("tx_hash") or request.query_params.get("tx_hash")
926+
sender_id = request.data.get("sender_id") or request.query_params.get("sender_id")
927+
928+
if not tx_hash or not sender_id:
929+
return Response(
930+
{"error": "tx_hash and sender_id are required"},
931+
status=400,
932+
)
933+
934+
# Fetch transaction and parse delete_list events
935+
tx_result = fetch_tx_result(tx_hash, sender_id)
936+
if not tx_result:
937+
return Response({"error": "Transaction not found"}, status=404)
938+
939+
delete_events = parse_events_from_tx(tx_result, "delete_list")
940+
941+
# Find the delete event for this specific list
942+
matching_event = None
943+
for event in delete_events:
944+
if event.get("list_id") == int(list_id):
945+
matching_event = event
946+
break
947+
948+
if not matching_event:
949+
return Response(
950+
{"error": f"No delete_list event found for list {list_id} in this transaction"},
951+
status=400,
952+
)
953+
954+
# Event verified — delete from DB
955+
deleted_count, _ = List.objects.filter(on_chain_id=int(list_id)).delete()
956+
957+
if deleted_count > 0:
958+
logger.info(f"List {list_id} deleted from DB (verified via tx {tx_hash})")
959+
return Response(
960+
{
961+
"success": True,
962+
"message": "List deleted",
963+
"on_chain_id": list_id,
964+
}
965+
)
966+
967+
return Response({"error": "List not found in database"}, status=404)
968+
969+
except Exception as e:
970+
logger.error(f"Error syncing list deletion {list_id}: {e}")
971+
return Response({"error": str(e)}, status=502)
972+
973+
974+
class ListUpvoteSyncAPI(APIView):
975+
"""
976+
Sync a list upvote from blockchain via tx_hash.
977+
978+
Called by frontend after user upvotes a list on-chain.
979+
Fetches the transaction to verify it succeeded, then creates the upvote record.
980+
"""
981+
982+
@extend_schema(
983+
summary="Sync list upvote from blockchain via tx_hash",
984+
parameters=[
985+
OpenApiParameter(name="tx_hash", required=True, type=str),
986+
OpenApiParameter(name="sender_id", required=True, type=str),
987+
],
988+
responses={
989+
200: OpenApiResponse(description="Upvote synced"),
990+
400: OpenApiResponse(description="Missing parameters"),
991+
404: OpenApiResponse(description="List not found"),
992+
502: OpenApiResponse(description="RPC failed"),
993+
},
994+
)
995+
def post(self, request, list_id: int):
996+
try:
997+
tx_hash = request.data.get("tx_hash") or request.query_params.get("tx_hash")
998+
sender_id = request.data.get("sender_id") or request.query_params.get("sender_id")
999+
1000+
if not tx_hash or not sender_id:
1001+
return Response(
1002+
{"error": "tx_hash and sender_id are required"},
1003+
status=400,
1004+
)
1005+
1006+
# Fetch transaction to verify it succeeded
1007+
tx_result = fetch_tx_result(tx_hash, sender_id)
1008+
if not tx_result:
1009+
return Response({"error": "Transaction not found"}, status=404)
1010+
1011+
# Find the list
1012+
try:
1013+
list_obj = List.objects.get(on_chain_id=int(list_id))
1014+
except List.DoesNotExist:
1015+
return Response({"error": "List not found in database"}, status=404)
1016+
1017+
# Create account if needed
1018+
account, _ = Account.objects.get_or_create(id=sender_id)
1019+
1020+
# Create or update the upvote
1021+
now = datetime.now()
1022+
ListUpvote.objects.update_or_create(
1023+
list=list_obj,
1024+
account=account,
1025+
defaults={"created_at": now},
1026+
)
1027+
1028+
# Create activity record
1029+
Activity.objects.update_or_create(
1030+
action_result={"list_id": int(list_id)},
1031+
type="Upvote",
1032+
defaults={
1033+
"signer": account,
1034+
"receiver_id": LISTS_CONTRACT,
1035+
"timestamp": now,
1036+
"tx_hash": tx_hash,
1037+
},
1038+
)
1039+
1040+
logger.info(f"List {list_id} upvoted by {sender_id} (verified via tx {tx_hash})")
1041+
return Response(
1042+
{
1043+
"success": True,
1044+
"message": "Upvote synced",
1045+
"on_chain_id": list_id,
1046+
}
1047+
)
1048+
1049+
except Exception as e:
1050+
logger.error(f"Error syncing list upvote {list_id}: {e}")
1051+
return Response({"error": str(e)}, status=502)
1052+
1053+
1054+
class ListRemoveUpvoteSyncAPI(APIView):
1055+
"""
1056+
Sync a list remove-upvote from blockchain via tx_hash.
1057+
1058+
Called by frontend after user removes their upvote from a list on-chain.
1059+
Fetches the transaction to verify it succeeded, then deletes the upvote record.
1060+
"""
1061+
1062+
@extend_schema(
1063+
summary="Sync list remove-upvote from blockchain via tx_hash",
1064+
parameters=[
1065+
OpenApiParameter(name="tx_hash", required=True, type=str),
1066+
OpenApiParameter(name="sender_id", required=True, type=str),
1067+
],
1068+
responses={
1069+
200: OpenApiResponse(description="Upvote removed"),
1070+
400: OpenApiResponse(description="Missing parameters"),
1071+
404: OpenApiResponse(description="List not found"),
1072+
502: OpenApiResponse(description="RPC failed"),
1073+
},
1074+
)
1075+
def post(self, request, list_id: int):
1076+
try:
1077+
tx_hash = request.data.get("tx_hash") or request.query_params.get("tx_hash")
1078+
sender_id = request.data.get("sender_id") or request.query_params.get("sender_id")
1079+
1080+
if not tx_hash or not sender_id:
1081+
return Response(
1082+
{"error": "tx_hash and sender_id are required"},
1083+
status=400,
1084+
)
1085+
1086+
# Fetch transaction to verify it succeeded
1087+
tx_result = fetch_tx_result(tx_hash, sender_id)
1088+
if not tx_result:
1089+
return Response({"error": "Transaction not found"}, status=404)
1090+
1091+
# Find the list
1092+
try:
1093+
list_obj = List.objects.get(on_chain_id=int(list_id))
1094+
except List.DoesNotExist:
1095+
return Response({"error": "List not found in database"}, status=404)
1096+
1097+
# Delete the upvote
1098+
ListUpvote.objects.filter(list=list_obj, account_id=sender_id).delete()
1099+
1100+
logger.info(f"Upvote removed from list {list_id} by {sender_id} (verified via tx {tx_hash})")
1101+
return Response(
1102+
{
1103+
"success": True,
1104+
"message": "Upvote removed",
1105+
"on_chain_id": list_id,
1106+
}
1107+
)
1108+
1109+
except Exception as e:
1110+
logger.error(f"Error syncing list remove-upvote {list_id}: {e}")
1111+
return Response({"error": str(e)}, status=502)
1112+
1113+
8461114
class AccountSyncAPI(APIView):
8471115
"""
8481116
Sync account data and recalculate donation stats.

api/urls.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
ListSyncAPI,
2323
ListRegistrationsSyncAPI,
2424
SingleRegistrationSyncAPI,
25+
ListDeleteSyncAPI,
26+
ListUpvoteSyncAPI,
27+
ListRemoveUpvoteSyncAPI,
2528
PotSyncAPI,
2629
PotDonationsSyncAPI,
2730
PotApplicationsSyncAPI,
@@ -202,6 +205,21 @@
202205
DirectDonationSyncAPI.as_view(),
203206
name="direct_donation_sync_api",
204207
),
208+
path(
209+
"v1/lists/<int:list_id>/delete/sync",
210+
ListDeleteSyncAPI.as_view(),
211+
name="list_delete_sync_api",
212+
),
213+
path(
214+
"v1/lists/<int:list_id>/upvote/sync",
215+
ListUpvoteSyncAPI.as_view(),
216+
name="list_upvote_sync_api",
217+
),
218+
path(
219+
"v1/lists/<int:list_id>/remove-upvote/sync",
220+
ListRemoveUpvoteSyncAPI.as_view(),
221+
name="list_remove_upvote_sync_api",
222+
),
205223
# pot sync endpoints
206224
path(
207225
"v1/pots/<str:pot_id>/sync",

0 commit comments

Comments
 (0)