Skip to content

Commit

Permalink
Add leaderboard by campaign (#100)
Browse files Browse the repository at this point in the history
- Add get leaderboard list for a given campaign.
- Add get leaderboard data for a given campaign and address.
  • Loading branch information
moisses89 authored May 21, 2024
1 parent 6d47603 commit 0d725b3
Show file tree
Hide file tree
Showing 6 changed files with 360 additions and 6 deletions.
57 changes: 56 additions & 1 deletion safe_locking_service/campaigns/models.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,35 @@
import uuid
from decimal import Decimal
from typing import List, TypedDict

from django.db import models
from django.db import connection, models
from django.db.backends.utils import CursorWrapper
from django.utils.text import slugify

from eth_typing import ChecksumAddress
from hexbytes import HexBytes

from gnosis.eth.django.models import EthereumAddressV2Field


class LeaderBoardCampaignRow(TypedDict):
address: ChecksumAddress
total_campaign_points: int
total_campaign_boosted_points: Decimal
position: int


def fetch_all_from_cursor(cursor: CursorWrapper) -> List[LeaderBoardCampaignRow]:
"""
:param cursor:
:return: all rows from a db cursor as a List of `LeaderBoardCampaignRow`.
"""
columns = [col[0] for col in cursor.description]

return [dict(zip(columns, row)) for row in cursor.fetchall()]


class Campaign(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True)
name = models.CharField(max_length=50)
Expand Down Expand Up @@ -70,3 +94,34 @@ class ActivityMetadata(models.Model):
name = models.CharField(max_length=50)
description = models.CharField(blank=True)
max_points = models.PositiveBigIntegerField()


def get_campaign_leader_board_position(
uuid: str, address: ChecksumAddress
) -> LeaderBoardCampaignRow:
"""
:return: a Dict of LeaderBoardCampaignRow
"""

query = """
Select * FROM
(SELECT "campaigns_activity"."address",
SUM("campaigns_activity"."total_points") AS "total_campaign_points",
SUM("campaigns_activity"."total_boosted_points") AS "total_campaign_boosted_points",
ROW_NUMBER() OVER (ORDER BY SUM("campaigns_activity"."total_boosted_points") DESC) AS "position"
FROM "campaigns_activity"
INNER JOIN "campaigns_period"
ON ("campaigns_activity"."period_id" = "campaigns_period"."id")
INNER JOIN "campaigns_campaign"
ON ("campaigns_period"."campaign_id" = "campaigns_campaign"."id")
WHERE "campaigns_campaign"."uuid" = %s::uuid
GROUP BY "campaigns_activity"."address"
ORDER BY "total_campaign_boosted_points" DESC) AS LEADERTABLE where address = %s;
"""

with connection.cursor() as cursor:
holder_address = HexBytes(address)
cursor.execute(query, [uuid, holder_address])
if result := fetch_all_from_cursor(cursor):
return result[0]
30 changes: 30 additions & 0 deletions safe_locking_service/campaigns/serializers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
from typing import Dict

from rest_framework import serializers

from gnosis.eth.utils import fast_to_checksum_address

from safe_locking_service.campaigns.models import Campaign


Expand All @@ -25,3 +29,29 @@ def get_resource_id(self, obj: Campaign):

def get_last_updated(self, obj: Campaign):
return obj.last_updated


class CampaignLeaderBoardSerializer(serializers.Serializer):
holder = serializers.SerializerMethodField()
position = serializers.IntegerField()
boost = serializers.SerializerMethodField()
total_points = serializers.SerializerMethodField()
total_boosted_points = serializers.SerializerMethodField()

def get_holder(self, obj: Dict):
if isinstance(obj["address"], str):
return obj["address"]
return fast_to_checksum_address(bytes(obj["address"]))

def get_total_boosted_points(self, obj: Dict):
return obj["total_campaign_boosted_points"]

def get_boost(self, obj: Dict):
return (
obj["total_campaign_boosted_points"] / obj["total_campaign_points"]
if obj["total_campaign_points"]
else 0
)

def get_total_points(self, obj: Dict):
return obj["total_campaign_points"]
14 changes: 13 additions & 1 deletion safe_locking_service/campaigns/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

from django.utils import timezone

from eth_account import Account
from factory import Faker, LazyFunction, SubFactory
from factory.django import DjangoModelFactory
from factory.fuzzy import FuzzyText

from ..models import ActivityMetadata, Campaign, Period
from ..models import Activity, ActivityMetadata, Campaign, Period


class CampaignFactory(DjangoModelFactory):
Expand All @@ -28,6 +29,17 @@ class Meta:
end_date = LazyFunction(lambda: timezone.now().date())


class ActivityFactory(DjangoModelFactory):
class Meta:
model = Activity

period = SubFactory(PeriodFactory)
address = LazyFunction(lambda: Account.create().address)
total_points = Faker("pyint")
boost = Faker("pyint")
total_boosted_points = Faker("pyint")


class ActivityMetadataFactory(DjangoModelFactory):
class Meta:
model = ActivityMetadata
Expand Down
183 changes: 182 additions & 1 deletion safe_locking_service/campaigns/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@
from django.urls import reverse
from django.utils import timezone

from eth_account import Account
from faker import Faker
from rest_framework import status

from ...campaigns.tests.factories import ActivityMetadataFactory, CampaignFactory
from ...campaigns.tests.factories import (
ActivityFactory,
ActivityMetadataFactory,
CampaignFactory,
)
from ...utils.timestamp_helper import get_formated_timestamp
from ..forms import FileUploadForm
from .csv_factory import CSVFactory
Expand Down Expand Up @@ -235,3 +240,179 @@ def test_wrong_header(self, task_mock):
"Invalid CSV format: File does not include one or more of the following headers:"
)
)

def test_empty_leaderboard_campaigns_view(self):
resource_id = str(CampaignFactory().uuid)
response = self.client.get(
reverse("v1:locking_campaigns:leaderboard-campaign", args=(resource_id,)),
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# No campaigns
response_json = response.json()
self.assertEqual(response_json["count"], 0)

def test_leaderboard_campaigns_view(self):
campaign = CampaignFactory()
previous_day = timezone.now().date() - timedelta(days=1)
period_1 = PeriodFactory(
campaign=campaign, start_date=previous_day, end_date=previous_day
)
period_2 = PeriodFactory(campaign=campaign)
safe_address_position_1 = Account.create().address
safe_address_position_2 = Account.create().address
ActivityFactory(
period=period_1,
address=safe_address_position_1,
total_points=100,
boost=2,
total_boosted_points=200,
)
ActivityFactory(
period=period_1,
address=safe_address_position_2,
total_points=100,
boost=1,
total_boosted_points=100,
)
ActivityFactory(
period=period_2,
address=safe_address_position_1,
total_points=100,
boost=2,
total_boosted_points=200,
)
ActivityFactory(
period=period_2,
address=safe_address_position_2,
total_points=100,
boost=1,
total_boosted_points=100,
)

resource_id = campaign.uuid
response = self.client.get(
reverse("v1:locking_campaigns:leaderboard-campaign", args=(resource_id,)),
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# No campaigns
response_json = response.json()
self.assertEqual(response_json["count"], 2)

position_1 = response_json["results"][0]
self.assertEqual(position_1["holder"], safe_address_position_1)
self.assertEqual(position_1["position"], 1)
self.assertEqual(position_1["totalPoints"], 200)
self.assertEqual(position_1["boost"], 2)
self.assertEqual(position_1["totalBoostedPoints"], 400)

position_2 = response_json["results"][1]
self.assertEqual(position_2["holder"], safe_address_position_2)
self.assertEqual(position_2["position"], 2)
self.assertEqual(position_2["totalPoints"], 200)
self.assertEqual(position_2["boost"], 1)
self.assertEqual(position_2["totalBoostedPoints"], 200)

# Should pass position 2 to 1
next_day = timezone.now().date() + timedelta(days=1)
period_3 = PeriodFactory(
campaign=campaign, start_date=next_day, end_date=next_day
)
ActivityFactory(
period=period_3,
address=safe_address_position_2,
total_points=200,
boost=2,
total_boosted_points=400,
)
response = self.client.get(
reverse("v1:locking_campaigns:leaderboard-campaign", args=(resource_id,)),
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
response_json = response.json()
self.assertEqual(response_json["count"], 2)
first_position = response_json["results"][0]
self.assertEqual(first_position["holder"], safe_address_position_2)
self.assertEqual(first_position["position"], 1)
self.assertEqual(first_position["totalPoints"], 400)
self.assertEqual(first_position["boost"], 1.5)
self.assertEqual(first_position["totalBoostedPoints"], 600)

def test_leaderboard_campaign_position_view(self):
# Should return 404 error
response = self.client.get(
reverse(
"v1:locking_campaigns:leaderboard-campaign-position",
args=(uuid.uuid4(), Account.create().address),
),
format="json",
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

campaign = CampaignFactory()
previous_day = timezone.now().date() - timedelta(days=1)
period_1 = PeriodFactory(
campaign=campaign, start_date=previous_day, end_date=previous_day
)
period_2 = PeriodFactory(campaign=campaign)
safe_address_position_1 = Account.create().address
safe_address_position_2 = Account.create().address

ActivityFactory(
period=period_1,
address=safe_address_position_1,
total_points=100,
boost=2,
total_boosted_points=200,
)
ActivityFactory(
period=period_1,
address=safe_address_position_2,
total_points=100,
boost=1,
total_boosted_points=100,
)
ActivityFactory(
period=period_2,
address=safe_address_position_1,
total_points=100,
boost=2,
total_boosted_points=200,
)
ActivityFactory(
period=period_2,
address=safe_address_position_2,
total_points=100,
boost=1,
total_boosted_points=100,
)
resource_id = campaign.uuid
response = self.client.get(
reverse(
"v1:locking_campaigns:leaderboard-campaign-position",
args=(resource_id, safe_address_position_1),
),
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
position_1 = response.json()
self.assertEqual(position_1["holder"], safe_address_position_1)
self.assertEqual(position_1["position"], 1)
self.assertEqual(position_1["totalPoints"], 200)
self.assertEqual(position_1["boost"], 2)
self.assertEqual(position_1["totalBoostedPoints"], 400)
response = self.client.get(
reverse(
"v1:locking_campaigns:leaderboard-campaign-position",
args=(resource_id, safe_address_position_2),
),
format="json",
)
position_2 = response.json()
self.assertEqual(position_2["holder"], safe_address_position_2)
self.assertEqual(position_2["position"], 2)
self.assertEqual(position_2["totalPoints"], 200)
self.assertEqual(position_2["boost"], 1)
self.assertEqual(position_2["totalBoostedPoints"], 200)
10 changes: 10 additions & 0 deletions safe_locking_service/campaigns/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,14 @@
views.RetrieveCampaignView.as_view(),
name="retrieve-campaign",
),
path(
"<str:resource_id>/leaderboard/",
views.CampaignLeaderBoardView.as_view(),
name="leaderboard-campaign",
),
path(
"<str:resource_id>/leaderboard/<str:address>/",
views.CampaignLeaderBoardPositionView.as_view(),
name="leaderboard-campaign-position",
),
]
Loading

0 comments on commit 0d725b3

Please sign in to comment.