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 16, 2022
1 parent b0201d4 commit bc3515b
Show file tree
Hide file tree
Showing 4 changed files with 207 additions and 0 deletions.
128 changes: 128 additions & 0 deletions home/tests/unit/api/test_currency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import json
from datetime import datetime

from dateutil.relativedelta import relativedelta
from django.test import Client, TestCase
from django.utils import timezone
from home.models.contest import Contest
from home.utils import localize
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().astimezone(timezone.get_default_timezone())
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):
start = self.now
end = self.now + relativedelta(hours=1)

generator = IntentionalWalkGenerator([self.device])
next(generator.generate(1, steps=steps, start=start, end=end))

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()
self.assertEqual(data.get("status"), "error")
self.assertEqual(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()
self.assertEqual(data.get("status"), "error")
self.assertEqual(
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")
self.assertEqual(payload.get("steps"), STEPS_PER_COIN)
self.assertEqual(payload.get("coins"), 1)
self.assertEqual(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")
self.assertEqual(payload.get("steps"), STEPS_PER_BADGE)
self.assertEqual(payload.get("coins"), 0)
self.assertEqual(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")
self.assertEqual(payload.get("steps"), steps)
self.assertEqual(payload.get("coins"), 2)
self.assertEqual(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")
self.assertEqual(payload.get("steps"), steps)
self.assertEqual(payload.get("coins"), 0)
self.assertEqual(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
73 changes: 73 additions & 0 deletions home/views/api/currency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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 home.utils import localize

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=localize(contest.start),
end__lte=localize(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 bc3515b

Please sign in to comment.