-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <[email protected]>
- Loading branch information
Showing
4 changed files
with
207 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}, | ||
} | ||
) |