Skip to content

Commit c5b9d24

Browse files
authored
Merge pull request #174 from PotLock/testnet
Testnet -> Staging
2 parents d688f0f + bc3ccf5 commit c5b9d24

File tree

4 files changed

+360
-0
lines changed

4 files changed

+360
-0
lines changed

campaigns/management/__init__.py

Whitespace-only changes.

campaigns/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
import time
2+
from datetime import datetime
3+
import requests
4+
from django.conf import settings
5+
from django.core.management.base import BaseCommand
6+
from django.utils import timezone
7+
8+
from accounts.models import Account
9+
from campaigns.models import Campaign, CampaignDonation
10+
from tokens.models import Token
11+
12+
13+
14+
CAMPAIGN_CONTRACT_ID =f"v1.campaign.{settings.POTLOCK_TLA}" if settings.ENVIRONMENT=="testnet" else f"v1.campaigns.staging.{settings.POTLOCK_TLA}"
15+
16+
class Command(BaseCommand):
17+
help = "Pull campaigns data from contract & populate campaigns table."
18+
19+
def add_arguments(self, parser):
20+
parser.add_argument(
21+
'--campaign-id',
22+
type=int,
23+
help='Populate specific campaign by ID (optional)',
24+
)
25+
parser.add_argument(
26+
'--skip-donations',
27+
action='store_true',
28+
help='Skip fetching donations for campaigns',
29+
)
30+
parser.add_argument(
31+
'--limit',
32+
type=int,
33+
default=100,
34+
help='Limit number of campaigns to fetch (default: 100)',
35+
)
36+
37+
def handle(self, *args, **options):
38+
39+
self.stdout.write(
40+
self.style.SUCCESS(f'Starting to populate campaign data from {CAMPAIGN_CONTRACT_ID}')
41+
)
42+
43+
if options['campaign_id']:
44+
# Fetch specific campaign
45+
self.fetch_single_campaign(CAMPAIGN_CONTRACT_ID, options['campaign_id'], options['skip_donations'])
46+
else:
47+
# Fetch all campaigns
48+
self.fetch_all_campaigns(CAMPAIGN_CONTRACT_ID, options['limit'], options['skip_donations'])
49+
50+
self.stdout.write(
51+
self.style.SUCCESS('Successfully populated campaign data')
52+
)
53+
54+
def fetch_all_campaigns(self, contract_id, limit, skip_donations):
55+
"""Fetch all campaigns from the contract"""
56+
57+
# Get campaigns
58+
url = f"{settings.FASTNEAR_RPC_URL}/account/{contract_id}/view/get_campaigns"
59+
params = {
60+
"from_index.json": 0,
61+
"limit.json": limit,
62+
}
63+
64+
self.stdout.write(f"Fetching campaigns from {url}")
65+
66+
response = requests.get(url, params=params)
67+
if response.status_code != 200:
68+
self.stdout.write(
69+
self.style.ERROR(
70+
f"Request for campaigns data failed ({response.status_code}) with message: {response.text}"
71+
)
72+
)
73+
return
74+
75+
campaigns = response.json()
76+
self.stdout.write(f"Found {len(campaigns)} campaigns")
77+
78+
for campaign_data in campaigns:
79+
self.process_campaign(campaign_data, skip_donations)
80+
# Small delay to avoid rate limiting
81+
time.sleep(0.1)
82+
83+
def fetch_single_campaign(self, contract_id, campaign_id, skip_donations):
84+
"""Fetch a specific campaign by ID"""
85+
86+
url = f"{settings.FASTNEAR_RPC_URL}/account/{contract_id}/view/get_campaign"
87+
params = {
88+
"campaign_id.json": campaign_id,
89+
}
90+
91+
self.stdout.write(f"Fetching campaign {campaign_id} from {url}")
92+
93+
response = requests.get(url, params=params)
94+
if response.status_code != 200:
95+
self.stdout.write(
96+
self.style.ERROR(
97+
f"Request for campaign {campaign_id} failed ({response.status_code}) with message: {response.text}"
98+
)
99+
)
100+
return
101+
102+
campaign_data = response.json()
103+
self.process_campaign(campaign_data, skip_donations)
104+
105+
def process_campaign(self, campaign_data, skip_donations):
106+
"""Process and save a single campaign"""
107+
108+
try:
109+
campaign_id = campaign_data["id"]
110+
self.stdout.write(f"Processing campaign {campaign_id}: {campaign_data.get('name', 'Unnamed')}")
111+
112+
# Get or create accounts
113+
owner, _ = Account.objects.get_or_create(
114+
defaults={"chain_id": 1},
115+
id=campaign_data["owner"]
116+
)
117+
recipient, _ = Account.objects.get_or_create(
118+
defaults={"chain_id": 1},
119+
id=campaign_data["recipient"]
120+
)
121+
122+
# Get token if specified
123+
124+
ft_id = campaign_data.get("ft_id") or "near"
125+
token_acct, token_acct_created = Account.objects.get_or_create(defaults={"chain_id":1},id=ft_id)
126+
token_defaults = {
127+
"decimals": 24,
128+
}
129+
if token_acct_created:
130+
print(f"Created new token account: {token_acct}")
131+
if ft_id != "near":
132+
url = f"{settings.FASTNEAR_RPC_URL}/account/{ft_id}/view/ft_metadata"
133+
ft_metadata = requests.get(url)
134+
if ft_metadata.status_code != 200:
135+
self.stdout.write(
136+
self.style.ERROR(
137+
f"Request for campaigns data failed ({ft_metadata.status_code}) with message: {ft_metadata.text}"
138+
)
139+
)
140+
return
141+
else:
142+
ft_metadata = ft_metadata.json()
143+
if "name" in ft_metadata:
144+
token_defaults["name"] = ft_metadata["name"]
145+
if "symbol" in ft_metadata:
146+
token_defaults["symbol"] = ft_metadata["symbol"]
147+
if "icon" in ft_metadata:
148+
token_defaults["icon"] = ft_metadata["icon"]
149+
if "decimals" in ft_metadata:
150+
token_defaults["decimals"] = ft_metadata["decimals"]
151+
token, _ = Token.objects.get_or_create(
152+
account=token_acct, defaults=token_defaults
153+
)
154+
155+
# Convert timestamps to datetime objects
156+
start_at = datetime.fromtimestamp(campaign_data["start_ms"] / 1000)
157+
end_at = None
158+
if campaign_data.get("end_ms"):
159+
end_at = datetime.fromtimestamp(campaign_data["end_ms"] / 1000)
160+
created_at = datetime.fromtimestamp(campaign_data["created_ms"] / 1000)
161+
162+
# Campaign defaults
163+
campaign_defaults = {
164+
"owner": owner,
165+
"name": campaign_data["name"],
166+
"description": campaign_data.get("description"),
167+
"cover_image_url": campaign_data.get("cover_image_url"),
168+
"recipient": recipient,
169+
"token": token,
170+
"start_at": start_at,
171+
"end_at": end_at,
172+
"created_at": created_at,
173+
"target_amount": str(campaign_data["target_amount"]),
174+
"min_amount": str(campaign_data.get("min_amount", "")) if campaign_data.get("min_amount") else None,
175+
"max_amount": str(campaign_data.get("max_amount", "")) if campaign_data.get("max_amount") else None,
176+
"total_raised_amount": str(campaign_data.get("total_raised_amount", "0")),
177+
"net_raised_amount": str(campaign_data.get("net_raised_amount", "0")),
178+
"escrow_balance": str(campaign_data.get("escrow_balance", "0")),
179+
"referral_fee_basis_points": campaign_data["referral_fee_basis_points"],
180+
"creator_fee_basis_points": campaign_data["creator_fee_basis_points"],
181+
"allow_fee_avoidance": campaign_data.get("allow_fee_avoidance", False),
182+
}
183+
184+
# Create or update campaign
185+
campaign, created = Campaign.objects.update_or_create(
186+
on_chain_id=campaign_id,
187+
defaults=campaign_defaults
188+
)
189+
190+
action = "Created" if created else "Updated"
191+
self.stdout.write(f" {action} campaign: {campaign.on_chain_id}")
192+
193+
# Fetch USD prices for the campaign
194+
try:
195+
campaign.fetch_usd_prices()
196+
self.stdout.write(f" Fetched USD prices for campaign {campaign.on_chain_id}")
197+
except Exception as e:
198+
self.stdout.write(
199+
self.style.WARNING(f" Failed to fetch USD prices for campaign {campaign.on_chain_id}: {e}")
200+
)
201+
202+
# Fetch donations for this campaign if not skipped
203+
if not skip_donations:
204+
self.fetch_campaign_donations(campaign)
205+
206+
except Exception as e:
207+
self.stdout.write(
208+
self.style.ERROR(f"Failed to process campaign {campaign_data.get('id', 'unknown')}: {e}")
209+
)
210+
211+
def fetch_campaign_donations(self, campaign):
212+
"""Fetch donations for a specific campaign"""
213+
214+
# CAMPAIGN_CONTRACT_ID = f"v1.campaigns.staging.{settings.POTLOCK_TLA}"
215+
216+
# Get donations for campaign with pagination
217+
page = 0
218+
limit = 100
219+
total_donations = 0
220+
221+
while True:
222+
url = f"{settings.FASTNEAR_RPC_URL}/account/{CAMPAIGN_CONTRACT_ID}/view/get_donations_for_campaign"
223+
params = {
224+
"campaign_id.json": campaign.on_chain_id,
225+
"from_index.json": page * limit,
226+
"limit.json": limit,
227+
}
228+
229+
self.stdout.write(f" Fetching donations page {page + 1} for campaign {campaign.on_chain_id}")
230+
231+
response = requests.get(url, params=params)
232+
if response.status_code != 200:
233+
self.stdout.write(
234+
self.style.WARNING(
235+
f" Request for donations failed ({response.status_code}) with message: {response.text}"
236+
)
237+
)
238+
break
239+
240+
donations = response.json()
241+
if not donations:
242+
break
243+
244+
self.stdout.write(f" Processing {len(donations)} donations")
245+
246+
for donation_data in donations:
247+
self.process_campaign_donation(donation_data, campaign)
248+
total_donations += 1
249+
250+
# Break if we got fewer results than the limit (last page)
251+
if len(donations) < limit:
252+
break
253+
254+
page += 1
255+
# Small delay to avoid rate limiting
256+
time.sleep(0.1)
257+
258+
self.stdout.write(f" Processed {total_donations} donations for campaign {campaign.on_chain_id}")
259+
260+
def process_campaign_donation(self, donation_data, campaign):
261+
"""Process and save a single campaign donation"""
262+
263+
try:
264+
donation_id = donation_data["id"]
265+
266+
# Get or create donor
267+
donor, _ = Account.objects.get_or_create(
268+
defaults={"chain_id": 1},
269+
id=donation_data["donor_id"]
270+
)
271+
272+
# Get referrer if present
273+
referrer = None
274+
if donation_data.get("referrer_id"):
275+
referrer, _ = Account.objects.get_or_create(
276+
defaults={"chain_id": 1},
277+
id=donation_data["referrer_id"]
278+
)
279+
280+
# Convert timestamp
281+
donated_at = datetime.fromtimestamp(donation_data["donated_at_ms"] / 1000)
282+
283+
# Handle returned_at if present
284+
returned_at = None
285+
if donation_data.get("returned_at_ms"):
286+
returned_at = datetime.fromtimestamp(donation_data["returned_at_ms"] / 1000)
287+
288+
# Donation defaults
289+
donation_defaults = {
290+
"campaign": campaign,
291+
"donor": donor,
292+
"token": campaign.token, # Use campaign's token
293+
"total_amount": str(donation_data["total_amount"]),
294+
"net_amount": str(donation_data["net_amount"]),
295+
"message": donation_data.get("message"),
296+
"donated_at": donated_at,
297+
"protocol_fee": str(donation_data["protocol_fee"]),
298+
"referrer": referrer,
299+
"referrer_fee": str(donation_data.get("referrer_fee", "")) if donation_data.get("referrer_fee") else None,
300+
"creator_fee": str(donation_data["creator_fee"]),
301+
"returned_at": returned_at,
302+
"escrowed": donation_data.get("escrowed", False),
303+
"tx_hash": donation_data.get("tx_hash"),
304+
}
305+
306+
# Create or update donation
307+
donation, created = CampaignDonation.objects.update_or_create(
308+
on_chain_id=donation_id,
309+
campaign=campaign,
310+
defaults=donation_defaults
311+
)
312+
313+
if created:
314+
self.stdout.write(f" Created donation: {donation.on_chain_id}")
315+
316+
# Fetch USD prices for the donation
317+
try:
318+
donation.fetch_usd_prices()
319+
except Exception as e:
320+
self.stdout.write(
321+
self.style.WARNING(f" Failed to fetch USD prices for donation {donation.on_chain_id}: {e}")
322+
)
323+
324+
except Exception as e:
325+
self.stdout.write(
326+
self.style.ERROR(f" Failed to process donation {donation_data.get('id', 'unknown')}: {e}")
327+
)

campaigns/serializers.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from django.utils import timezone
12
from rest_framework import serializers
23
from rest_framework.serializers import (
34
ModelSerializer,
@@ -40,11 +41,42 @@ class Meta:
4041
"owner",
4142
"recipient",
4243
"token",
44+
"is_active",
4345
]
4446

4547
owner = AccountSerializer()
4648
recipient = AccountSerializer()
4749
token = TokenSerializer()
50+
is_active = serializers.SerializerMethodField()
51+
52+
def get_is_active(self, obj):
53+
"""
54+
Check if campaign is active based on:
55+
1. Campaign has started (start_at <= current_time)
56+
2. Campaign hasn't ended yet (end_at > current_time or end_at is None)
57+
3. Campaign hasn't reached max amount (net_raised_amount < max_amount or max_amount is None)
58+
"""
59+
60+
now = timezone.now()
61+
62+
if obj.start_at > now:
63+
return False
64+
65+
if obj.end_at is not None and obj.end_at <= now:
66+
return False
67+
68+
if obj.max_amount is not None:
69+
try:
70+
net_raised = int(obj.net_raised_amount)
71+
max_amount = int(obj.max_amount)
72+
if net_raised >= max_amount:
73+
return False
74+
except (ValueError, TypeError):
75+
# If we can't parse the amounts, assume not maxed out
76+
pass
77+
78+
return True
79+
4880

4981

5082
class CampaignDonationSerializer(ModelSerializer):
@@ -103,6 +135,7 @@ class Meta:
103135
"referral_fee_basis_points": 500,
104136
"creator_fee_basis_points": 250,
105137
"allow_fee_avoidance": False,
138+
"is_active": True,
106139
"owner": SIMPLE_ACCOUNT_EXAMPLE,
107140
"recipient": SIMPLE_ACCOUNT_EXAMPLE,
108141
"token": SIMPLE_TOKEN_EXAMPLE,

0 commit comments

Comments
 (0)