Skip to content

Commit

Permalink
Merge pull request #258 from planetarium/release/0.12.0
Browse files Browse the repository at this point in the history
Release 0.12.0
  • Loading branch information
U-lis authored Mar 26, 2024
2 parents 7ed30e9 + 55a04f4 commit e7408fd
Show file tree
Hide file tree
Showing 16 changed files with 1,857 additions and 1,142 deletions.
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

0 comments on commit e7408fd

Please sign in to comment.