diff --git a/home/tests/unit/api/test_currency.py b/home/tests/unit/api/test_currency.py new file mode 100644 index 00000000..9d1bd7b6 --- /dev/null +++ b/home/tests/unit/api/test_currency.py @@ -0,0 +1,127 @@ +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.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="plum@clue.net", + 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) diff --git a/home/urls.py b/home/urls.py index ef81cd39..fbb6d1f8 100644 --- a/home/urls.py +++ b/home/urls.py @@ -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(), diff --git a/home/views/__init__.py b/home/views/__init__.py index a9ecf132..f226b575 100644 --- a/home/views/__init__.py +++ b/home/views/__init__.py @@ -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 diff --git a/home/views/api/currency.py b/home/views/api/currency.py new file mode 100644 index 00000000..e0f83c8f --- /dev/null +++ b/home/views/api/currency.py @@ -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, + }, + } + )