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

Release 0.12.0 #258

Merged
merged 32 commits into from
Mar 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
e01c305
Record error message to DB
U-lis Mar 15, 2024
d6b53bc
Update packages
U-lis Mar 18, 2024
d344cf3
Merge pull request #245 from planetarium/release/0.11.0
U-lis Mar 18, 2024
8ee48ad
Add debug-toolbar for optimization
U-lis Mar 18, 2024
6fee136
Optimize queries
U-lis Mar 18, 2024
203c671
Merge branch 'development' into bugfix/product-api
U-lis Mar 18, 2024
dac0bd8
Remove unused codes
U-lis Mar 18, 2024
d9c5ff0
Add missing query clauses
U-lis Mar 18, 2024
22ce15c
Refactoring views
U-lis Mar 18, 2024
baa6dd9
Optimize query using group_by to get purhcase history
U-lis Mar 19, 2024
08baa8f
Add all product list API
U-lis Mar 20, 2024
1bc47c4
Default of buyable is False
U-lis Mar 20, 2024
50c98f4
Remove order_by for faster query
U-lis Mar 20, 2024
ce1e4ad
Add AvatarLevel model to cache level
U-lis Mar 20, 2024
7caa704
Create migrate script
U-lis Mar 20, 2024
fdf5903
Use cached data while checking required level
U-lis Mar 20, 2024
8ae43c0
Add cache library
U-lis Mar 20, 2024
905b256
Add cache to all product list API
U-lis Mar 20, 2024
8162b9b
Merge pull request #250 from planetarium/bugfix/product-api
U-lis Mar 20, 2024
1692a31
Merge branch 'development' into feature/move-views
U-lis Mar 21, 2024
12ecdd4
Merge pull request #251 from planetarium/feature/move-views
U-lis Mar 21, 2024
4c323c2
Merge branch 'development' into feature/backup-required-level
U-lis Mar 21, 2024
2a764a3
Import debugtoolbar inside if clause
U-lis Mar 21, 2024
934cc1e
Merge pull request #252 from planetarium/feature/backup-required-level
U-lis Mar 21, 2024
2422d6b
Merge pull request #253 from planetarium/bugfix/debug-toolbar
U-lis Mar 21, 2024
287fb5e
Merge pull request #254 from planetarium/development
U-lis Mar 21, 2024
8a6d3eb
Consume valid purchases
U-lis Mar 25, 2024
4c90eb6
Fix wrong error message
U-lis Mar 25, 2024
2cc8b07
Remove unused codes
U-lis Mar 25, 2024
26ce25b
Merge pull request #256 from planetarium/bugfix/auto-refund
U-lis Mar 26, 2024
7fa42dc
Merge branch 'main' into release/0.12.0
U-lis Mar 26, 2024
55a04f4
Update version to 0.12.0
U-lis Mar 26, 2024
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
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
# CHANGELOG of NineChronicles.IAP

## 0.12.0 (2024-03-27)

### Feature

- Add AvatarLevel table and cache level data
- Add all product list API

### Enhancement

- Move all views into `/views` router
- Optimize Queries

### Bugfix

- Consume valid purchase to avoid unintended refund

## 0.5.2 (2023-11-08)

### Enhancement

- Change GQL scheme from `exceptionName` to `exceptionNames` to apply GQL scheme update


## 0.5.1 (2023-10-23)

### Bugfix
Expand Down
39 changes: 39 additions & 0 deletions common/alembic/versions/f6de778b7fe3_add_avatarlevel_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Add AvatarLevel table

Revision ID: f6de778b7fe3
Revises: 90ff6ac09fe5
Create Date: 2024-03-20 11:39:30.281858

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = 'f6de778b7fe3'
down_revision = '90ff6ac09fe5'
branch_labels = None
depends_on = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('avatar_level',
sa.Column('agent_addr', sa.Text(), nullable=False),
sa.Column('avatar_addr', sa.Text(), nullable=False),
sa.Column('planet_id', sa.LargeBinary(length=12), nullable=False),
sa.Column('level', sa.Integer(), nullable=False),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_avatar_planet', 'avatar_level', ['avatar_addr', 'planet_id'], unique=False)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('idx_avatar_planet', table_name='avatar_level')
op.drop_table('avatar_level')
# ### end Alembic commands ###
1 change: 1 addition & 0 deletions common/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
"receipt",
"product",
"voucher",
"user",
]
20 changes: 20 additions & 0 deletions common/models/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from sqlalchemy import Column, Text, LargeBinary, Integer, Index

from common.models.base import Base, AutoIdMixin, TimeStampMixin
from common.utils.receipt import PlanetID


class AvatarLevel(AutoIdMixin, TimeStampMixin, Base):
"""
AvatarLevel is some sort of cache table checking required level.
"""
__tablename__ = "avatar_level"
agent_addr = Column(Text, nullable=False, doc="9c agent address where to get FAVs")
avatar_addr = Column(Text, nullable=False, doc="9c avatar's address where to get items")
planet_id = Column(LargeBinary(length=12), nullable=False, default=PlanetID.ODIN.value,
doc="An identifier of planets")
level = Column(Integer, nullable=False, doc="Cached max level of avatar")

__table_args__ = (
Index("idx_avatar_planet", avatar_addr, planet_id),
)
56 changes: 30 additions & 26 deletions iap/api/product.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@
from typing import List

from fastapi import APIRouter, Depends
from fastapi_cache.decorator import cache
from sqlalchemy import select
from sqlalchemy.orm import joinedload

from common.models.product import Product, Category
from common.utils.address import format_addr
from common.utils.receipt import PlanetID
from iap import settings
from iap.dependencies import session
from iap.schemas.product import CategorySchema, ProductSchema
from iap.utils import get_purchase_count
from iap.schemas.product import CategorySchema, ProductSchema, SimpleProductSchema
from iap.utils import get_purchase_history

router = APIRouter(
prefix="/product",
Expand All @@ -19,50 +21,46 @@


@router.get("", response_model=List[CategorySchema])
def product_list(agent_addr: str,
planet_id: str = "",
sess=Depends(session)):
def product_list(agent_addr: str, planet_id: str = "", sess=Depends(session)):
if not planet_id:
planet_id = PlanetID.ODIN if settings.stage == "mainnet" else PlanetID.ODIN_INTERNAL
else:
planet_id = PlanetID(bytes(planet_id, "utf-8"))

agent_addr = format_addr(agent_addr).lower()
# FIXME: Optimize query
all_category_list = (
sess.query(Category).options(joinedload(Category.product_list))
.join(Product.fav_list)
.join(Product.fungible_item_list)
.filter(Category.active.is_(True)).filter(Product.active.is_(True))
.order_by(Category.order, Product.order)
).all()
all_category_list = sess.scalars(
select(Category)
.options(
joinedload(Category.product_list).joinedload(Product.fav_list),
joinedload(Category.product_list).joinedload(Product.fungible_item_list),
)
.where(Category.active.is_(True))
).unique().fetchall()

category_schema_list = []
purchase_history = get_purchase_history(sess, planet_id, agent_addr)
for category in all_category_list:
cat_schema = CategorySchema.model_validate(category)
schema_dict = {}
for product in category.product_list:
# Skip non-active products
if ((product.open_timestamp and product.open_timestamp > datetime.now()) or
(product.close_timestamp and product.close_timestamp <= datetime.now())):
schema = ProductSchema.model_validate(product)
if (not product.active or
((product.open_timestamp and product.open_timestamp > datetime.now()) or
(product.close_timestamp and product.close_timestamp <= datetime.now()))
):
schema.active = False
schema.buyable = False
continue

# Check purchase history
schema = ProductSchema.model_validate(product)
if product.daily_limit:
schema.purchase_count = get_purchase_count(
sess, product.id, planet_id=planet_id, agent_addr=agent_addr, daily_limit=True
)
schema.purchase_count = purchase_history["daily"][product.id]
schema.buyable = schema.purchase_count < product.daily_limit
elif product.weekly_limit:
schema.purchase_count = get_purchase_count(
sess, product.id, planet_id=planet_id, agent_addr=agent_addr, weekly_limit=True
)
schema.purchase_count = purchase_history["weekly"][product.id]
schema.buyable = schema.purchase_count < product.weekly_limit
elif product.account_limit:
schema.purchase_count = get_purchase_count(
sess, product.id, planet_id=planet_id, agent_addr=agent_addr
)
schema.purchase_count = purchase_history["account"][product.id]
schema.buyable = schema.purchase_count < product.account_limit
else: # Product with no limitation
schema.buyable = True
Expand All @@ -73,3 +71,9 @@ def product_list(agent_addr: str,
category_schema_list.append(cat_schema)

return category_schema_list


@router.get("/all", response_model=List[SimpleProductSchema])
@cache(expire=3600)
def all_product_list(sess=Depends(session)):
return sess.scalars(select(Product)).fetchall()
73 changes: 40 additions & 33 deletions iap/api/purchase.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,15 @@
import boto3
import requests
from fastapi import APIRouter, Depends, Query
from googleapiclient.errors import HttpError
from sqlalchemy import select, or_
from sqlalchemy.orm import joinedload
from starlette.responses import JSONResponse

from common.enums import ReceiptStatus, Store
from common.models.product import Product
from common.models.receipt import Receipt
from common.models.user import AvatarLevel
from common.utils.aws import fetch_parameter
from common.utils.google import get_google_client
from common.utils.receipt import PlanetID
from iap import settings
from iap.dependencies import session
Expand All @@ -27,7 +26,7 @@
from iap.utils import create_season_pass_jwt, get_purchase_count
from iap.validator.apple import validate_apple
from iap.validator.common import get_order_data
from iap.validator.google import validate_google
from iap.validator.google import validate_google, consume_google

router = APIRouter(
prefix="/purchase",
Expand All @@ -39,18 +38,6 @@
VOUCHER_SQS_URL = os.environ.get("VOUCHER_SQS_URL")


def consume_google(sku: str, token: str):
client = get_google_client(settings.GOOGLE_CREDENTIAL)
try:
resp = client.purchases().products().consume(
packageName=settings.GOOGLE_PACKAGE_NAME, productId=sku, token=token
)
logger.debug(resp)
except HttpError as e:
logger.error(e)
raise e


def raise_error(sess, receipt: Receipt, e: Exception):
sess.add(receipt)
sess.commit()
Expand All @@ -75,24 +62,44 @@ def log_request_product(planet_id: str, agent_address: str, avatar_address: str,

def check_required_level(sess, receipt: Receipt, product: Product) -> Receipt:
if product.required_level:
gql_url = None
if receipt.planet_id in (PlanetID.ODIN, PlanetID.ODIN_INTERNAL):
gql_url = os.environ.get("ODIN_GQL_URL")
elif receipt.planet_id in (PlanetID.HEIMDALL, PlanetID.HEIMDALL_INTERNAL):
gql_url = os.environ.get("HEIMDALL_GQL_URL")

query = f"""{{ stateQuery {{ avatar (avatarAddress: "{receipt.avatar_addr}") {{ level}} }} }}"""
try:
resp = requests.post(gql_url, json={"query": query}, timeout=1)
avatar_level = resp.json()["data"]["stateQuery"]["avatar"]["level"]
except:
# Whether request is failed or no fitted data found
avatar_level = 0
cached_data = sess.scalar(select(AvatarLevel).where(
AvatarLevel.avatar_addr == receipt.avatar_addr,
AvatarLevel.planet_id == receipt.planet_id)
)
if not cached_data:
cached_data = AvatarLevel(
agent_addr=receipt.agent_addr,
avatar_addr=receipt.avatar_addr,
planet_id=receipt.planet_id,
level=-1
)

if avatar_level < product.required_level:
# Fetch and update current level
if cached_data.level < product.required_level:
gql_url = None
if receipt.planet_id in (PlanetID.ODIN, PlanetID.ODIN_INTERNAL):
gql_url = os.environ.get("ODIN_GQL_URL")
elif receipt.planet_id in (PlanetID.HEIMDALL, PlanetID.HEIMDALL_INTERNAL):
gql_url = os.environ.get("HEIMDALL_GQL_URL")

query = f"""{{ stateQuery {{ avatar (avatarAddress: "{receipt.avatar_addr}") {{ level}} }} }}"""
try:
resp = requests.post(gql_url, json={"query": query}, timeout=1)
cached_data.level = resp.json()["data"]["stateQuery"]["avatar"]["level"]
except:
# Whether request is failed or no fitted data found
pass

# NOTE: Do not commit here to prevent unintended data save during process
sess.add(cached_data)

# Final check
if cached_data.level < product.required_level:
receipt.status = ReceiptStatus.REQUIRED_LEVEL
raise_error(sess, receipt,
ValueError(f"Avatar level {avatar_level} does not met required level {product.required_level}"))
msg = f"Avatar level {cached_data.level} does not met required level {product.required_level}"
receipt.msg = msg
raise_error(sess, receipt, ValueError(msg))

return receipt


Expand Down Expand Up @@ -211,8 +218,8 @@ def request_product(receipt_data: ReceiptSchema, sess=Depends(session)):
# receipt.status = ReceiptStatus.INVALID
# raise_error(sess, receipt, ValueError(
# f"Invalid Product ID: Given {product.google_sku} is not identical to found from receipt: {purchase.productId}"))
# NOTE: Consume can be executed only by purchase owner.
# consume_google(product_id, token)
if success:
consume_google(product_id, token)
## Apple
elif receipt_data.store in (Store.APPLE, Store.APPLE_TEST):
success, msg, purchase = validate_apple(order_id)
Expand Down
6 changes: 3 additions & 3 deletions iap/frontend/src/components/Navigation.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
</script>

<Navbar let:hidden let:toggle color="form">
<NavBrand href={stageUrl("/")}>
<NavBrand href={stageUrl("/views/")}>
<img src="/favicon.png" alt="9c" class="mr-3 h-6 sm:h-9"/>
<span class="self-center whitespace-nowrap text-xl dark:text-white">9c IAP: {STAGE}</span>
</NavBrand>
<NavHamburger on:click={toggle}/>
<NavUl {hidden}>
<NavLi href={stageUrl("/")} active={current === "home"}>Home</NavLi>
<NavLi href={stageUrl("/receipt")} active={current === "receipt"}>Receipt List</NavLi>
<NavLi href={stageUrl("/views/")} active={current === "home"}>Home</NavLi>
<NavLi href={stageUrl("/views/receipt")} active={current === "receipt"}>Receipt List</NavLi>
</NavUl>
</Navbar>

Loading
Loading