|
9 | 9 | POST /api/v1/lists/{list_id}/sync - Sync single list |
10 | 10 | POST /api/v1/lists/{list_id}/registrations/sync - Sync all registrations |
11 | 11 | 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 |
12 | 15 |
|
13 | 16 | Accounts: |
14 | 17 | POST /api/v1/accounts/{account_id}/sync - Sync account profile and recalculate stats |
|
20 | 23 |
|
21 | 24 | import requests |
22 | 25 | 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 |
24 | 27 | from rest_framework.response import Response |
25 | 28 | from rest_framework.views import APIView |
26 | 29 |
|
27 | 30 | from accounts.models import Account |
| 31 | +from activities.models import Activity |
28 | 32 | from donations.models import Donation |
29 | | -from lists.models import List, ListRegistration |
| 33 | +from lists.models import List, ListRegistration, ListUpvote |
30 | 34 | from pots.models import Pot, PotApplication, PotApplicationReview, PotFactory, PotPayout, PotPayoutChallenge, PotPayoutChallengeAdminResponse |
31 | 35 | from tokens.models import Token |
32 | 36 |
|
@@ -119,6 +123,58 @@ def fetch_from_rpc(method_name: str, args: dict = None, contract_id: str = None, |
119 | 123 | raise Exception(f"All RPC endpoints failed. Last error: {last_error}") |
120 | 124 |
|
121 | 125 |
|
| 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 | + |
122 | 178 | class ListSyncAPI(APIView): |
123 | 179 | """ |
124 | 180 | Sync a list from blockchain to database. |
@@ -843,6 +899,218 @@ def post(self, request, pot_id: str): |
843 | 899 | return Response({"error": str(e)}, status=502) |
844 | 900 |
|
845 | 901 |
|
| 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 | + |
846 | 1114 | class AccountSyncAPI(APIView): |
847 | 1115 | """ |
848 | 1116 | Sync account data and recalculate donation stats. |
|
0 commit comments