Skip to content

Commit

Permalink
Merge pull request #369 from KSG-IT/feature/stock-market-mode
Browse files Browse the repository at this point in the history
Feature/stock market mode
  • Loading branch information
alexaor authored Nov 10, 2023
2 parents 9151a8b + 0f1d91c commit 87ccc59
Show file tree
Hide file tree
Showing 20 changed files with 1,536 additions and 804 deletions.
5 changes: 1 addition & 4 deletions .github/workflows/test_on_push.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
name: Run tests on PRs

on:
push:
branches:
- develop
on: push

jobs:
test:
Expand Down
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,26 @@
# CHANGELOG

[Unreleased]
## [Unreleased]
### Added
- Stock market mode
- Feature flag based mode that triggers a new purchase mode emulating the fluctuations of a stock market
- X-APP purchases made through the REST API will calculate a price based on the item purchase price and popularity
- Ghost product purchases to track non-digital pucrhases made
- Stock market crash model to manually crash the market through am utation
- Queries to show stock market item and its price change
- A whole lot of tests

### Fixed
- Economy
- Broken statistics query

### Removed
- Economy
- Redundant is_default field annotation in product resolver

## [2023.9.1] - 2023-09-12

### Changed
- Economy
- Automatically close stale soci order sessions when attempting to create a new one.
- Add resolvers for sales statistics on products
Expand Down
131 changes: 131 additions & 0 deletions api/tests/tests.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from datetime import timedelta
from random import randint

Expand All @@ -14,6 +15,8 @@
SociSessionFactory,
ProductOrderFactory,
)
from common.tests.factories import FeatureFlagFactory
from django.conf import settings
from users.tests.factories import UserFactory


Expand Down Expand Up @@ -270,3 +273,131 @@ def test_get_balance__no_card_provided__bad_request(self):
response = self.client.get(self.url, {})

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)


class ChargeAccountStockMarketEnabled(APITestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.client = APIClient()

def setUp(self):
self.user_account = SociBankAccountFactory(user__is_staff=True)
self.tuborg = SociProductFactory.create(
name="tuborg", sku_number="TBGR", price=25, purchase_price=20
)
self.tuborg_no_purchase_price = SociProductFactory.create(
name="tuborg", sku_number="TBGR2", price=25
)
self.ice = SociProductFactory.create(
name="Smirnoff Ice", sku_number="ICE", price=45, purchase_price=39
)
self.ice_no_purchase_price = SociProductFactory.create(
name="Smirnoff Ice", sku_number="ICE2", price=45
)
self.client.force_authenticate(self.user_account.user)
self.url = reverse("api:charge")

SociSessionFactory.create()
self.initial_funds = 1000
self.user_account.add_funds(self.initial_funds)
self.flag = FeatureFlagFactory.create(
name=settings.X_APP_STOCK_MARKET_MODE, enabled=True
)

def test__no_existing_sales__charge_purchase_price(self):
ice_order_size = 1
tuborg_order_size = 2
self.client.post(
self.url,
{
"bank_account_id": f"{self.user_account.id}",
"products": [
{
"sku": self.ice_no_purchase_price.sku_number,
"order_size": ice_order_size,
},
{
"sku": self.tuborg_no_purchase_price.sku_number,
"order_size": tuborg_order_size,
},
],
},
format="json",
)
self.user_account.refresh_from_db()
account_charge = self.initial_funds - self.user_account.balance
expected_ice_cost = ice_order_size * self.ice_no_purchase_price.price
expected_tuborg_cost = tuborg_order_size * self.tuborg_no_purchase_price.price
expected_total_cost = expected_tuborg_cost + expected_ice_cost

self.assertEqual(account_charge, expected_total_cost)

def test__no_purchase_price_set__charge_ordinary_price(self):
ice_order_size = 1
tuborg_order_size = 2
self.client.post(
self.url,
{
"bank_account_id": f"{self.user_account.id}",
"products": [
{"sku": self.ice.sku_number, "order_size": ice_order_size},
{"sku": self.tuborg.sku_number, "order_size": tuborg_order_size},
],
},
format="json",
)
self.user_account.refresh_from_db()
account_charge = self.initial_funds - self.user_account.balance
expected_ice_cost = ice_order_size * self.ice.purchase_price
expected_tuborg_cost = tuborg_order_size * self.tuborg.purchase_price
expected_total_cost = expected_tuborg_cost + expected_ice_cost

self.assertEqual(account_charge, expected_total_cost)


class ChargeAccountStockMarketDisabled(APITestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.client = APIClient()

def setUp(self):
self.user_account = SociBankAccountFactory(user__is_staff=True)
self.tuborg = SociProductFactory.create(
name="tuborg", sku_number="TBGR", price=25, purchase_price=20
)
self.ice = SociProductFactory.create(
name="Smirnoff Ice", sku_number="ICE", price=45, purchase_price=39
)
self.client.force_authenticate(self.user_account.user)
self.url = reverse("api:charge")

SociSessionFactory.create()
self.initial_funds = 1000
self.user_account.add_funds(self.initial_funds)
self.flag = FeatureFlagFactory.create(
name=settings.X_APP_STOCK_MARKET_MODE, enabled=False
)

def test__stock_mode_disabled__charge_ordinary_price(self):
ice_order_size = 1
tuborg_order_size = 2
self.client.post(
self.url,
{
"bank_account_id": f"{self.user_account.id}",
"products": [
{"sku": self.ice.sku_number, "order_size": ice_order_size},
{"sku": self.tuborg.sku_number, "order_size": tuborg_order_size},
],
},
format="json",
)
self.user_account.refresh_from_db()
account_charge = self.initial_funds - self.user_account.balance
expected_ice_cost = ice_order_size * self.ice.price
expected_tuborg_cost = tuborg_order_size * self.tuborg.price
expected_total_cost = expected_tuborg_cost + expected_ice_cost

self.assertEqual(account_charge, expected_total_cost)
17 changes: 16 additions & 1 deletion api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@
CustomTokenObtainSlidingSerializer,
BlacklistedSongSerializer,
)
from economy.price_strategies import calculate_stock_price_for_product

from sensors.consts import MEASUREMENT_TYPE_TEMPERATURE, MEASUREMENT_TYPE_CHOICES
from sensors.models import SensorMeasurement
from api.view_mixins import CustomCreateAPIView
from economy.models import SociBankAccount, SociProduct, SociSession, ProductOrder
from ksg_nett.custom_authentication import CardNumberAuthentication
from django.conf import settings
from common.util import check_feature_flag


class CustomTokenObtainSlidingView(TokenObtainSlidingView):
Expand Down Expand Up @@ -249,17 +251,30 @@ def post(self, request, *args, **kwargs):
status=status.HTTP_400_BAD_REQUEST,
)
order_size = product_order["order_size"]

if (
check_feature_flag(settings.X_APP_STOCK_MARKET_MODE, fail_silently=True)
and product.purchase_price
): # Stock mode is enabled and the product has a registered purchase price
product_price = calculate_stock_price_for_product(product.id)
else:
product_price = product.price

orders.append(
ProductOrder(
product=product,
order_size=product_order["order_size"],
cost=order_size * product.price,
cost=order_size * product_price,
source=account,
session=session,
)
)

total_cost = sum([order.cost for order in orders])

if not total_cost:
raise RuntimeError("Could not determine purchase cost")

if account.balance < total_cost and not account.is_gold:
return Response(
{"message": "Insufficient funds"}, status=status.HTTP_400_BAD_REQUEST
Expand Down
8 changes: 8 additions & 0 deletions common/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
from admissions.models import Admission
from common.decorators import gql_has_permissions
from common.models import FeatureFlag
from common.util import check_feature_flag
from schedules.schemas.schedules import ShiftSlotNode
from summaries.schema import SummaryNode
from summaries.models import Summary
from quotes.schema import QuoteNode
from quotes.models import Quote
from users.schema import UserNode
from economy.models import SociBankAccount, Deposit
from django.conf import settings


class FeatureFlagNode(DjangoObjectType):
Expand All @@ -30,6 +32,7 @@ class DashboardData(graphene.ObjectType):
)
soci_order_session = graphene.Field("economy.schema.SociOrderSessionNode")
show_newbies = graphene.Boolean()
show_stock_market_shortcut = graphene.Boolean()


class SidebarData(graphene.ObjectType):
Expand Down Expand Up @@ -67,13 +70,18 @@ def resolve_dashboard_data(self, info, *args, **kwargs):
else:
delta_since_closed = timezone.now() - admission.closed_at
show_newbies = delta_since_closed.days < 30

show_stock_market_shortcut = check_feature_flag(
settings.X_APP_STOCK_MARKET_MODE, fail_silently=True
)
return DashboardData(
last_quotes=quotes,
last_summaries=summaries,
wanted_list=wanted,
my_upcoming_shifts=upcoming_shifts,
soci_order_session=soci_order_session,
show_newbies=show_newbies,
show_stock_market_shortcut=show_stock_market_shortcut,
)


Expand Down
12 changes: 12 additions & 0 deletions common/tests/factories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from common.models import FeatureFlag

import pytz
from django.utils import timezone
from factory import SubFactory, Faker, Sequence, post_generation
from factory.django import DjangoModelFactory
from factory.django import ImageField


class FeatureFlagFactory(DjangoModelFactory):
class Meta:
model = FeatureFlag
6 changes: 6 additions & 0 deletions economy/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
SociOrderSession,
SociOrderSessionOrder,
ExternalCharge,
ProductGhostOrder,
)


Expand Down Expand Up @@ -85,3 +86,8 @@ class UserSociOrderSessionCollectionAdmin(admin.ModelAdmin):
@admin.register(ExternalCharge)
class ExternalChargeAdmin(admin.ModelAdmin):
pass


@admin.register(ProductGhostOrder)
class ProductGhostOrderAdmin(admin.ModelAdmin):
pass
22 changes: 22 additions & 0 deletions economy/migrations/0003_sociproduct_purchase_price.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 4.2.3 on 2023-10-26 20:01

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("economy", "0002_socisession_minimum_remaining_balance"),
]

operations = [
migrations.AddField(
model_name="sociproduct",
name="purchase_price",
field=models.IntegerField(
blank=True,
help_text="What the product is valued when purchasing it for inventory",
null=True,
),
),
]
37 changes: 37 additions & 0 deletions economy/migrations/0004_productghostorder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Generated by Django 4.2.3 on 2023-10-27 13:30

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
("economy", "0003_sociproduct_purchase_price"),
]

operations = [
migrations.CreateModel(
name="ProductGhostOrder",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("timestamp", models.DateTimeField(auto_now=True)),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ghost_purchases",
to="economy.sociproduct",
),
),
],
),
]
28 changes: 28 additions & 0 deletions economy/migrations/0005_stockmarketcrash.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 4.2.3 on 2023-11-09 19:54

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("economy", "0004_productghostorder"),
]

operations = [
migrations.CreateModel(
name="StockMarketCrash",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("timestamp", models.DateTimeField(auto_now=True)),
],
),
]
Loading

0 comments on commit 87ccc59

Please sign in to comment.