Skip to content

Commit

Permalink
feat: POST /api/currency/get, a step currency backend
Browse files Browse the repository at this point in the history
This route does a very simple thing: it converts the number of
steps that a device has into a coin + badge value, and presents
those currency values to the requesting user.

It will also provide us a "single source of truth" when it comes
to currency calculations for the application.

In the front-end, this route should be used to obtain a server-side,
up-to-date record of these gamified currencies. The value can then be
cached in client state to deal with user interaction animations.

Signed-off-by: Kevin Morris <[email protected]>
  • Loading branch information
kevr committed Sep 15, 2022
1 parent b0201d4 commit 8f67724
Show file tree
Hide file tree
Showing 4 changed files with 208 additions and 0 deletions.
130 changes: 130 additions & 0 deletions home/tests/unit/api/test_currency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import json
from datetime import datetime

from dateutil.relativedelta import relativedelta
from django.test import Client, TestCase
from home.models.contest import Contest
from home.utils.generators import (
AccountGenerator,
DeviceGenerator,
IntentionalWalkGenerator,
)
from home.views.api.currency import STEPS_PER_BADGE, STEPS_PER_COIN


class TestCurrency(TestCase):
def setUp(self):
self.account = next(
AccountGenerator().generate(
1,
email="[email protected]",
name="Professor Plum",
)
)

self.device = next(DeviceGenerator([self.account]).generate(1))

self.now = datetime.now()
start_promo = self.now - relativedelta(days=15 + 7)
start = start_promo + relativedelta(days=7)
end = self.now + relativedelta(days=15)

self.contest = Contest.objects.create(
start_promo=start_promo.date(),
start=start.date(),
end=end.date(),
)

self.client = Client()

def generate_steps(self, steps: int):
generator = IntentionalWalkGenerator([self.device])
next(
generator.generate(
1,
steps=steps,
start=self.now,
end=self.now + relativedelta(hours=1),
)
)

def post_request(self):
response = self.client.post(
"/api/currency/get",
json.dumps({"account_id": str(self.device.device_id)}),
content_type="application/json",
)
return response.json()

def test_currency_no_contest(self):
# Delete the Contest record we created
self.contest.delete()

# Make a POST request, expect the no active contest error
response = self.client.post(
"/api/currency/get",
json.dumps({"account_id": str(self.device.device_id)}),
content_type="application/json",
)
data = response.json()
assert data.get("status") == "error"
assert data.get("message") == "There is no active contest"

def test_currency_missing_account_id(self):
# Make a JSON POST request without any "account_id" key
response = self.client.post(
"/api/currency/get",
json.dumps({}),
content_type="application/json",
)

# Expect the account_id missing error
data = response.json()
assert data.get("status") == "error"
assert (
data.get("message")
== "Required input 'account_id' missing in the request"
)

def test_currency_coin(self):
"""Test that STEPS_PER_COIN translates into a single coin"""
self.generate_steps(STEPS_PER_COIN)

data = self.post_request()
payload = data.get("payload")
assert payload.get("steps") == STEPS_PER_COIN
assert payload.get("coins") == 1
assert payload.get("badges") == 0

def test_currency_badge(self):
"""Test that STEPS_PER_BADGE translates into a single badge
with no coins"""
self.generate_steps(STEPS_PER_BADGE)

data = self.post_request()
payload = data.get("payload")
assert payload.get("steps") == STEPS_PER_BADGE
assert payload.get("coins") == 0
assert payload.get("badges") == 1

def test_currency(self):
"""Test that enough step build up a badge and two coins"""
steps = STEPS_PER_BADGE + (STEPS_PER_COIN * 2)
self.generate_steps(steps)

data = self.post_request()
payload = data.get("payload")
assert payload.get("steps") == steps
assert payload.get("coins") == 2
assert payload.get("badges") == 1

def test_currency_none(self):
"""Test that without enough steps, we have no coins or badges"""
steps = 5
self.generate_steps(steps)

data = self.post_request()
payload = data.get("payload")
assert payload.get("steps") == steps
assert payload.get("coins") == 0
assert payload.get("badges") == 0
5 changes: 5 additions & 0 deletions home/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@
views.IntentionalWalkListView.as_view(),
name="intentionalwalk_get",
),
path(
"api/currency/get",
views.CurrencyView.as_view(),
name="currency_get",
),
path(
"api/contest/current",
views.ContestCurrentView.as_view(),
Expand Down
1 change: 1 addition & 0 deletions home/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from .api.dailywalk import DailyWalkCreateView, DailyWalkListView
from .api.intentionalwalk import IntentionalWalkView, IntentionalWalkListView
from .api.contest import ContestCurrentView
from .api.currency import CurrencyView

# Import web views
from .web.home import HomeView
Expand Down
72 changes: 72 additions & 0 deletions home/views/api/currency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import json

from django.http import JsonResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from home.models import Contest, IntentionalWalk

from .utils import validate_request_json

STEPS_PER_COIN = 2000
COINS_PER_BADGE = 5
STEPS_PER_BADGE = STEPS_PER_COIN * COINS_PER_BADGE


@method_decorator(csrf_exempt, name="dispatch")
class CurrencyView(View):
"""
Retrieve data regarding an account's gamified currency.
Gamificiation:
- 2000 steps are worth 1 "coin"
- 5 "coins" are worth 1 "badge"
"""

http_method_names = ["post"]

def post(self, request, *args, **kwargs):
json_data = json.loads(request.body)

json_status = validate_request_json(
json_data,
required_fields=["account_id"],
)
if "status" in json_status and json_status["status"] == "error":
return JsonResponse(json_status)

# get the current/next Contest
contest = Contest.active()
if contest is None:
return JsonResponse(
{
"status": "error",
"message": "There is no active contest",
}
)

device_id = json_data["account_id"]
walks = IntentionalWalk.objects.filter(
start__gte=contest.start,
end__lte=contest.end,
device=device_id,
).values("steps")
steps = sum(walk["steps"] for walk in walks)

badges = max(int(steps / STEPS_PER_BADGE), 0)

leftover_steps = max(steps - (badges * STEPS_PER_BADGE), 0)
coins = int(leftover_steps / STEPS_PER_COIN)

return JsonResponse(
{
"status": "success",
"payload": {
"contest_id": contest.contest_id,
"account_id": device_id,
"steps": steps,
"coins": coins,
"badges": badges,
},
}
)

0 comments on commit 8f67724

Please sign in to comment.