Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/stock market mode #369

Merged
merged 24 commits into from
Nov 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
59d4bb6
Merge pull request #363 from KSG-IT/develop
alexaor Sep 12, 2023
d268c5f
Merge remote-tracking branch 'origin/master' into develop
alexaor Oct 26, 2023
dfa38cd
feat(api): add feature-flag based alternate charge flow
alexaor Oct 26, 2023
0172e3e
feat(economy): add ghost order to stock price calculation
alexaor Oct 27, 2023
c7d6667
feat(economy): add simple stock market price query
alexaor Oct 27, 2023
550b05c
chore(economy): change poor naming convention
alexaor Oct 27, 2023
8b0febb
Merge pull request #370 from KSG-IT/feature/stock-market-price-query
alexaor Oct 27, 2023
1506667
feat(economy): ghost product mutation, tests and other misc.
alexaor Oct 31, 2023
4aad568
fix(admissions): broken location availability tests
alexaor Nov 2, 2023
f2e5f61
chore(economy): fix typo in market mode feature flag
alexaor Nov 2, 2023
73b597f
feat(CI): trigger test pipeline on push
alexaor Nov 2, 2023
b91dc53
Merge pull request #371 from KSG-IT/feature/ghost-product-mutation-an…
alexaor Nov 2, 2023
0387316
Merge remote-tracking branch 'origin/develop' into feature/stock-mark…
alexaor Nov 2, 2023
0edc71e
Merge remote-tracking branch 'origin/develop' into feature/stock-mark…
alexaor Nov 4, 2023
ad20d39
feat(economy): stock market mode price history resolver
alexaor Nov 6, 2023
a0c3a87
Merge pull request #373 from KSG-IT/feature/stock-price-history
alexaor Nov 9, 2023
1af0147
feat(economy): add percentage diff to stock market item
alexaor Nov 9, 2023
20f4e4b
fix(economy): stock price product field name typo
alexaor Nov 9, 2023
4edbd4a
feat(economy): add id field to stock market product
alexaor Nov 9, 2023
99863ec
feat(economy): improve percentage format stock produc
alexaor Nov 9, 2023
2862e1f
feat(common): add show stock market field to dashboard data
alexaor Nov 9, 2023
80f5832
feat(economy): add ghost order to dJango admin
alexaor Nov 9, 2023
6dda69c
feat(economy): add stock market crash
alexaor Nov 9, 2023
0f1d91c
chore: update changelog and versions
alexaor Nov 9, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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