Skip to content

Commit 68dc44b

Browse files
committed
feat: POST /api/currency/get, a step currency backend
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 <kevr@0cost.org>
1 parent b0201d4 commit 68dc44b

4 files changed

Lines changed: 206 additions & 0 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import json
2+
from datetime import datetime
3+
4+
from dateutil.relativedelta import relativedelta
5+
from django.test import Client, TestCase
6+
from django.utils import timezone
7+
from home.models.contest import Contest
8+
from home.utils.generators import (
9+
AccountGenerator,
10+
DeviceGenerator,
11+
IntentionalWalkGenerator,
12+
)
13+
from home.views.api.currency import STEPS_PER_BADGE, STEPS_PER_COIN
14+
15+
16+
class TestCurrency(TestCase):
17+
def setUp(self):
18+
self.account = next(
19+
AccountGenerator().generate(
20+
1,
21+
email="plum@clue.net",
22+
name="Professor Plum",
23+
)
24+
)
25+
26+
self.device = next(DeviceGenerator([self.account]).generate(1))
27+
28+
self.now = datetime.now().astimezone(timezone.get_default_timezone())
29+
start_promo = self.now - relativedelta(days=15 + 7)
30+
start = start_promo + relativedelta(days=7)
31+
end = self.now + relativedelta(days=15)
32+
33+
self.contest = Contest.objects.create(
34+
start_promo=start_promo.date(),
35+
start=start.date(),
36+
end=end.date(),
37+
)
38+
39+
self.client = Client()
40+
41+
def generate_steps(self, steps: int):
42+
start = self.now
43+
end = self.now + relativedelta(hours=1)
44+
45+
generator = IntentionalWalkGenerator([self.device])
46+
next(generator.generate(1, steps=steps, start=start, end=end))
47+
48+
def post_request(self):
49+
response = self.client.post(
50+
"/api/currency/get",
51+
json.dumps({"account_id": str(self.device.device_id)}),
52+
content_type="application/json",
53+
)
54+
return response.json()
55+
56+
def test_currency_no_contest(self):
57+
# Delete the Contest record we created
58+
self.contest.delete()
59+
60+
# Make a POST request, expect the no active contest error
61+
response = self.client.post(
62+
"/api/currency/get",
63+
json.dumps({"account_id": str(self.device.device_id)}),
64+
content_type="application/json",
65+
)
66+
data = response.json()
67+
self.assertEqual(data.get("status"), "error")
68+
self.assertEqual(data.get("message"), "There is no active contest")
69+
70+
def test_currency_missing_account_id(self):
71+
# Make a JSON POST request without any "account_id" key
72+
response = self.client.post(
73+
"/api/currency/get",
74+
json.dumps({}),
75+
content_type="application/json",
76+
)
77+
78+
# Expect the account_id missing error
79+
data = response.json()
80+
self.assertEqual(data.get("status"), "error")
81+
self.assertEqual(
82+
data.get("message"),
83+
"Required input 'account_id' missing in the request",
84+
)
85+
86+
def test_currency_coin(self):
87+
"""Test that STEPS_PER_COIN translates into a single coin"""
88+
self.generate_steps(STEPS_PER_COIN)
89+
90+
data = self.post_request()
91+
payload = data.get("payload")
92+
self.assertEqual(payload.get("steps"), STEPS_PER_COIN)
93+
self.assertEqual(payload.get("coins"), 1)
94+
self.assertEqual(payload.get("badges"), 0)
95+
96+
def test_currency_badge(self):
97+
"""Test that STEPS_PER_BADGE translates into a single badge
98+
with no coins"""
99+
self.generate_steps(STEPS_PER_BADGE)
100+
101+
data = self.post_request()
102+
payload = data.get("payload")
103+
self.assertEqual(payload.get("steps"), STEPS_PER_BADGE)
104+
self.assertEqual(payload.get("coins"), 0)
105+
self.assertEqual(payload.get("badges"), 1)
106+
107+
def test_currency(self):
108+
"""Test that enough step build up a badge and two coins"""
109+
steps = STEPS_PER_BADGE + (STEPS_PER_COIN * 2)
110+
self.generate_steps(steps)
111+
112+
data = self.post_request()
113+
payload = data.get("payload")
114+
self.assertEqual(payload.get("steps"), steps)
115+
self.assertEqual(payload.get("coins"), 2)
116+
self.assertEqual(payload.get("badges"), 1)
117+
118+
def test_currency_none(self):
119+
"""Test that without enough steps, we have no coins or badges"""
120+
steps = 5
121+
self.generate_steps(steps)
122+
123+
data = self.post_request()
124+
payload = data.get("payload")
125+
self.assertEqual(payload.get("steps"), steps)
126+
self.assertEqual(payload.get("coins"), 0)
127+
self.assertEqual(payload.get("badges"), 0)

home/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@
5757
views.IntentionalWalkListView.as_view(),
5858
name="intentionalwalk_get",
5959
),
60+
path(
61+
"api/currency/get",
62+
views.CurrencyView.as_view(),
63+
name="currency_get",
64+
),
6065
path(
6166
"api/contest/current",
6267
views.ContestCurrentView.as_view(),

home/views/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .api.dailywalk import DailyWalkCreateView, DailyWalkListView
44
from .api.intentionalwalk import IntentionalWalkView, IntentionalWalkListView
55
from .api.contest import ContestCurrentView
6+
from .api.currency import CurrencyView
67

78
# Import web views
89
from .web.home import HomeView

home/views/api/currency.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import json
2+
3+
from django.http import JsonResponse
4+
from django.utils.decorators import method_decorator
5+
from django.views import View
6+
from django.views.decorators.csrf import csrf_exempt
7+
from home.models import Contest, IntentionalWalk
8+
from home.utils import localize
9+
10+
from .utils import validate_request_json
11+
12+
STEPS_PER_COIN = 2000
13+
COINS_PER_BADGE = 5
14+
STEPS_PER_BADGE = STEPS_PER_COIN * COINS_PER_BADGE
15+
16+
17+
@method_decorator(csrf_exempt, name="dispatch")
18+
class CurrencyView(View):
19+
"""
20+
Retrieve data regarding an account's gamified currency.
21+
22+
Gamificiation:
23+
- 2000 steps are worth 1 "coin"
24+
- 5 "coins" are worth 1 "badge"
25+
"""
26+
27+
http_method_names = ["post"]
28+
29+
def post(self, request, *args, **kwargs):
30+
json_data = json.loads(request.body)
31+
32+
json_status = validate_request_json(
33+
json_data,
34+
required_fields=["account_id"],
35+
)
36+
if "status" in json_status and json_status["status"] == "error":
37+
return JsonResponse(json_status)
38+
39+
# get the current/next Contest
40+
contest = Contest.active()
41+
if contest is None:
42+
return JsonResponse(
43+
{
44+
"status": "error",
45+
"message": "There is no active contest",
46+
}
47+
)
48+
49+
device_id = json_data["account_id"]
50+
walks = IntentionalWalk.objects.filter(
51+
start__gte=localize(contest.start),
52+
end__lte=localize(contest.end),
53+
device=device_id,
54+
).values("steps")
55+
steps = sum(walk["steps"] for walk in walks)
56+
57+
badges = max(int(steps / STEPS_PER_BADGE), 0)
58+
59+
leftover_steps = max(steps - (badges * STEPS_PER_BADGE), 0)
60+
coins = int(leftover_steps / STEPS_PER_COIN)
61+
62+
return JsonResponse(
63+
{
64+
"status": "success",
65+
"payload": {
66+
"contest_id": contest.contest_id,
67+
"account_id": device_id,
68+
"steps": steps,
69+
"coins": coins,
70+
"badges": badges,
71+
},
72+
}
73+
)

0 commit comments

Comments
 (0)