Add price support

This commit is contained in:
Correl Roush 2021-07-17 03:18:49 -04:00
parent eb61663888
commit 0140c9c889
9 changed files with 206 additions and 26 deletions

View file

@ -24,6 +24,17 @@ CREATE TABLE IF NOT EXISTS `cards` (
`oracle_text` TEXT
);
CREATE TABLE IF NOT EXISTS `card_prices` (
`scryfall_id` TEXT,
`date` TEXT,
`usd` TEXT, -- Decimal value
`usd_foil` TEXT, -- Decimal value
`eur` TEXT, -- Decimal value
`eur_foil` TEXT, -- Decimal value
`tix` TEXT, -- Decimal value
PRIMARY KEY (`scryfall_id`, `date`)
);
CREATE TABLE IF NOT EXISTS `sets` (
`set_code` TEXT PRIMARY KEY,
`name` TEXT NOT NULL

View file

@ -1,3 +1,4 @@
import datetime
import json
import logging
@ -98,18 +99,22 @@ def update_scryfall(ctx, filename):
cards = json.loads(buffer)
async def import_cards():
today = datetime.date.today()
async with aiosqlite.connect(ctx.obj["database"]) as db:
with click.progressbar(
cards,
label=f"Importing {humanize.intcomma(len(cards))} cards",
) as bar:
for card_object in bar:
card = tutor.scryfall.to_card(card_object)
await tutor.database.store_card(
db, tutor.scryfall.to_card(card_object)
db, card
)
await tutor.database.store_price(db, today, card)
await tutor.database.store_set(
db, card_object["set"].upper(), card_object["set_name"]
)
await tutor.database.store_var(db, "last_update", str(today))
await db.commit()
tornado.ioloop.IOLoop.current().run_sync(import_cards)

View file

@ -94,6 +94,7 @@ async def advanced_search(
search: tutor.search.Search,
limit: int = 10,
offset: int = 0,
sort_by: str = "rarity",
in_collection: typing.Optional[bool] = None,
) -> typing.List[tutor.models.Card]:
db.row_factory = aiosqlite.Row
@ -119,20 +120,18 @@ async def advanced_search(
constraints.append(f"cards.color_identity LIKE :{param}")
params[param] = tutor.models.Color.to_string(criterion.colors)
if criterion.operator == tutor.search.Operator.lte:
colors = list({str(color) for color in criterion.colors} | {""})
constraints.append(
"({})".format(
" OR ".join(
[
f"cards.color_identity LIKE :{param}_{color}"
for color in criterion.colors
for color in colors
]
)
)
)
params.update(
{f"{param}_{color}": str(color) for color in criterion.colors}
)
params[param] = tutor.models.Color.to_string(criterion.colors)
params.update({f"{param}_{color}": color for color in colors})
if criterion.operator == tutor.search.Operator.gte:
constraints.append(f"cards.color_identity LIKE :{param}")
params[param] = "%{}%".format(
@ -160,24 +159,45 @@ async def advanced_search(
constraints.append("copies.id IS NULL")
joins.append("JOIN sets ON (cards.set_code = sets.set_code)")
joins.append("JOIN rarities ON (cards.rarity = rarities.rarity)")
joins.append(
"JOIN card_prices ON (cards.scryfall_id = card_prices.scryfall_id "
"AND card_prices.date = (select value from vars where key = :last_update_key))"
)
orderings = [
"rarities.rarity_ord DESC",
"length(cards.color_identity) DESC",
"CASE "
" WHEN length(cards.color_identity) > 0 THEN '0'"
" ELSE cards.color_identity END ASC",
"cards.name ASC",
]
if sort_by == "price":
orderings = [
"CAST(COALESCE(card_prices.usd, card_prices.usd_foil) as decimal) DESC",
*orderings,
]
params["last_update_key"] = "last_update"
query = " ".join(
[
"SELECT cards.* FROM cards",
"SELECT cards.*, card_prices.* FROM cards",
" ".join(joins),
"WHERE" if constraints else "",
" AND ".join(constraints),
"ORDER BY rarities.rarity_ord DESC",
", length(cards.color_identity) DESC",
", CASE ",
" WHEN length(cards.color_identity) > 0 THEN '0'"
" ELSE cards.color_identity END ASC",
", cards.name ASC",
"ORDER BY " if orderings else "",
", ".join(orderings),
f"LIMIT {offset},{limit}",
]
)
logger.debug("Query: %s", (query, params))
cursor = await db.execute(query, params)
rows = await cursor.fetchall()
def convert_price(price: typing.Optional[str]) -> typing.Optional[decimal.Decimal]:
if price:
return decimal.Decimal(price)
else:
return None
return [
tutor.models.Card(
scryfall_id=uuid.UUID(row["scryfall_id"]),
@ -193,6 +213,11 @@ async def advanced_search(
legalities={},
edhrec_rank=row["edhrec_rank"],
oracle_text=row["oracle_text"],
price_usd=convert_price(row["usd"]),
price_usd_foil=convert_price(row["usd_foil"]),
price_eur=convert_price(row["eur"]),
price_eur_foil=convert_price(row["eur_foil"]),
price_tix=convert_price(row["tix"]),
)
for row in rows
]
@ -253,6 +278,31 @@ async def store_card(db: aiosqlite.Connection, card: tutor.models.Card) -> None:
)
async def store_price(
db: aiosqlite.Connection, date: datetime.date, card: tutor.models.Card
) -> None:
await db.execute(
"INSERT INTO `card_prices`"
" (`scryfall_id`, `date`, `usd`, `usd_foil`, `eur`, `eur_foil`, `tix`) "
"VALUES (:scryfall_id, :date, :usd, :usd_foil, :eur, :eur_foil, :tix) "
"ON CONFLICT (`scryfall_id`, `date`) DO UPDATE "
"SET `usd` = :usd"
" , `usd_foil` = :usd_foil"
" , `eur` = :eur"
" , `eur_foil` = :eur_foil"
" , `tix` = :tix",
{
"scryfall_id": card.scryfall_id,
"date": str(date),
"usd": str(card.price_usd) if card.price_usd else None,
"usd_foil": str(card.price_usd_foil) if card.price_usd_foil else None,
"eur": str(card.price_eur) if card.price_eur else None,
"eur_foil": str(card.price_eur_foil) if card.price_eur_foil else None,
"tix": str(card.price_tix) if card.price_tix else None,
},
)
async def store_set(db: aiosqlite.Connection, set_code: str, name: str) -> None:
await db.execute(
"INSERT INTO `sets` (`set_code`, `name`) "
@ -272,3 +322,13 @@ async def store_copy(db: aiosqlite.Connection, copy: tutor.models.CardCopy) -> N
"condition": copy.condition,
},
)
async def store_var(db: aiosqlite.Connection, key: str, value: str) -> None:
await db.execute(
"INSERT INTO `vars` (`key`, `value`)"
"VALUES (:key, :value)"
"ON CONFLICT (`key`) DO UPDATE "
"SET `value` = :value",
{"key": key, "value": value},
)

View file

@ -48,17 +48,20 @@ class Rarity(enum.IntEnum):
except KeyError:
raise TypeError(f"Unknown rarity '{rarity}'")
class Game(enum.Enum):
Paper = "paper"
Arena = "arena"
MTGOnline = "mtgo"
class Legality(enum.Enum):
Legal = "legal"
NotLegal = "not_legal"
Restricted = "restricted"
Banned = "banned"
@dataclasses.dataclass
class Card:
scryfall_id: uuid.UUID
@ -74,6 +77,12 @@ class Card:
legalities: typing.Dict[str, Legality]
edhrec_rank: typing.Optional[int] = None
oracle_text: typing.Optional[str] = None
price_usd: typing.Optional[decimal.Decimal] = None
price_usd_foil: typing.Optional[decimal.Decimal] = None
price_eur: typing.Optional[decimal.Decimal] = None
price_eur_foil: typing.Optional[decimal.Decimal] = None
price_tix: typing.Optional[decimal.Decimal] = None
@dataclasses.dataclass
class CardCopy:

View file

@ -5,6 +5,9 @@ import tutor.models
def to_card(data: dict) -> tutor.models.Card:
prices = {
k: decimal.Decimal(v) if v else None for k, v in data.get("prices", {}).items()
}
return tutor.models.Card(
scryfall_id=data["id"],
name=data["name"],
@ -26,4 +29,9 @@ def to_card(data: dict) -> tutor.models.Card:
else None
),
oracle_text=data.get("oracle_text"),
price_usd=prices.get("usd"),
price_usd_foil=prices.get("usd_foil"),
price_eur=prices.get("eur"),
price_eur_foil=prices.get("eur_foil"),
price_tix=prices.get("tix"),
)

View file

@ -1,4 +1,6 @@
import decimal
import json
import typing
import urllib.parse
import aiosqlite
@ -59,12 +61,14 @@ class SearchHandler(tornado.web.RequestHandler):
in_collection = self.get_argument("in_collection", None)
page = max(1, int(self.get_argument("page", 1)))
limit = int(self.get_argument("limit", 10))
sort_by = self.get_argument("sort_by", "rarity")
search = tutor.search.search.parse(query)
cards = await tutor.database.advanced_search(
db,
search,
limit=limit + 1,
offset=limit * (page - 1),
sort_by=sort_by,
in_collection=in_collection in ("yes", "true")
if in_collection
else None,
@ -78,6 +82,11 @@ class SearchHandler(tornado.web.RequestHandler):
if has_more:
links["next"] = update_args(self.request.full_url(), page=page + 1)
self.set_links(**links)
def price(amount: typing.Optional[decimal.Decimal]) -> typing.Optional[str]:
if amount is not None:
return str(amount)
else:
return None
self.write(
json.dumps(
[
@ -91,6 +100,13 @@ class SearchHandler(tornado.web.RequestHandler):
card.color_identity
),
"oracle_text": card.oracle_text,
"prices": {
"usd": price(card.price_usd),
"usd_foil": price(card.price_usd_foil),
"eur": price(card.price_eur),
"eur_foil": price(card.price_eur_foil),
"tix": price(card.price_tix),
}
}
for card in cards[:limit]
]

View file

@ -16,6 +16,7 @@ import Element.Input as Input
import Html.Events
import Http
import Json.Decode
import Maybe.Extra
import Paginated
import Spinner
import Task
@ -33,6 +34,7 @@ type alias Dimensions =
type alias Criteria =
{ query : String
, sortBy : String
, ownedOnly : Bool
}
@ -62,6 +64,7 @@ type Msg
type CriteriaMsg
= UpdateName String
| UpdateSortBy String
| UpdateOwnedOnly Bool
@ -117,12 +120,13 @@ manaSpinner =
searchQuery : Criteria -> List Url.Builder.QueryParameter
searchQuery criteria =
[ Url.Builder.string "q" criteria.query
, Url.Builder.string "sort_by" criteria.sortBy
, Url.Builder.string "in_collection"
(if criteria.ownedOnly then
"yes"
else
""
"no"
)
]
@ -150,6 +154,15 @@ parseUrl =
Url.Parser.Query.string "q"
|> Url.Parser.Query.map (Maybe.withDefault "")
sortBy =
Url.Parser.Query.enum "sort_by"
(Dict.fromList
[ ( "rarity", "rarity" )
, ( "price", "price" )
]
)
|> Url.Parser.Query.map (Maybe.withDefault "rarity")
inCollection =
Url.Parser.Query.enum "in_collection"
(Dict.fromList
@ -161,14 +174,14 @@ parseUrl =
)
|> Url.Parser.Query.map (Maybe.withDefault True)
in
Url.Parser.top <?> Url.Parser.Query.map2 Criteria query inCollection
Url.Parser.top <?> Url.Parser.Query.map3 Criteria query sortBy inCollection
criteriaFromUrl : Url.Url -> Criteria
criteriaFromUrl url =
let
emptyCriteria =
{ query = "", ownedOnly = True }
{ query = "", sortBy = "rarity", ownedOnly = True }
in
Url.Parser.parse parseUrl url
|> Maybe.withDefault emptyCriteria
@ -207,6 +220,9 @@ updateCriteria msg model =
UpdateName text ->
{ model | query = text }
UpdateSortBy column ->
{ model | sortBy = column }
UpdateOwnedOnly value ->
{ model | ownedOnly = value }
@ -217,7 +233,7 @@ update msg model =
UrlChanged url ->
let
criteria =
criteriaFromUrl url
Debug.log "criteria" <| criteriaFromUrl url
in
( { model | criteria = criteria }, search criteria )
@ -241,19 +257,16 @@ update msg model =
, Cmd.none
)
UpdateSortBy _ ->
update Search { model | criteria = newCriteria }
UpdateOwnedOnly _ ->
( { model
| criteria = newCriteria
, cardPage = toLoading model.cardPage
}
, search newCriteria
)
update Search { model | criteria = newCriteria }
Search ->
( { model | cardPage = toLoading model.cardPage }
, Cmd.batch
[ search model.criteria
, Browser.Navigation.pushUrl model.navigationKey <|
[ Browser.Navigation.pushUrl model.navigationKey <|
Url.Builder.relative [] (searchQuery model.criteria)
]
)
@ -373,13 +386,41 @@ viewCardBrowser model =
Nothing ->
E.el [ E.width E.fill ] E.none
priceBadge { currency, amount } =
E.el
[ Border.solid
, Border.width 1
, Border.rounded 5
, Border.color colors.text
, E.width <| E.px 60
, E.padding 2
, Font.family [ Font.typeface "sans" ]
, Font.size 10
]
<|
E.row [ E.width E.fill ]
[ E.el [ E.width <| E.fillPortion 1 ] <| E.text <| String.toUpper currency
, E.el [ E.width <| E.fillPortion 2, Font.alignRight ] <| E.text amount
]
cardDetails card =
let
prices =
Maybe.Extra.values
[ Maybe.map (\usd -> { currency = "usd", amount = usd }) <|
Maybe.Extra.or card.prices.usd card.prices.usd_foil
, Maybe.map (\eur -> { currency = "eur", amount = eur }) <|
Maybe.Extra.or card.prices.eur card.prices.eur_foil
, Maybe.map (\tix -> { currency = "tix", amount = tix }) card.prices.tix
]
in
E.column
[ E.spacing 20
, E.padding 10
]
<|
E.el [ E.centerX ] (viewCard { width = 192, height = 272 } card)
:: (E.row [ E.spacing 5, E.centerX ] <| List.map priceBadge prices)
:: E.paragraph [ Font.heavy, Font.size 24, Font.center ] [ E.text card.name ]
:: List.map (\text -> E.paragraph [ Font.size 16 ] [ E.text text ])
(String.lines card.oracleText)
@ -494,6 +535,15 @@ view model =
{ onPress = Just Search
, label = E.text "Search"
}
, Input.radio [ E.padding 10 ]
{ onChange = UpdateCriteria << UpdateSortBy
, selected = Just model.criteria.sortBy
, label = Input.labelLeft [ Font.color colors.text ] (E.text "Sort by")
, options =
[ Input.option "rarity" <| E.el [ Font.color colors.text ] <| E.text "Rarity DESC"
, Input.option "price" <| E.el [ Font.color colors.text ] <| E.text "Price DESC"
]
}
, Input.checkbox []
{ onChange = UpdateCriteria << UpdateOwnedOnly
, icon = Input.defaultCheckbox

View file

@ -4,12 +4,22 @@ import Json.Decode
import Json.Decode.Pipeline as JDP
type alias Prices =
{ usd : Maybe String
, usd_foil : Maybe String
, eur : Maybe String
, eur_foil : Maybe String
, tix : Maybe String
}
type alias Card =
{ scryfallId : String
, name : String
, setCode : String
, rarity : String
, oracleText : String
, prices : Prices
}
@ -24,3 +34,14 @@ decode =
(Json.Decode.nullable Json.Decode.string
|> Json.Decode.map (Maybe.withDefault "")
)
|> JDP.required "prices" decodePrices
decodePrices : Json.Decode.Decoder Prices
decodePrices =
Json.Decode.succeed Prices
|> JDP.required "usd" (Json.Decode.nullable Json.Decode.string)
|> JDP.required "usd_foil" (Json.Decode.nullable Json.Decode.string)
|> JDP.required "eur" (Json.Decode.nullable Json.Decode.string)
|> JDP.required "eur_foil" (Json.Decode.nullable Json.Decode.string)
|> JDP.required "tix" (Json.Decode.nullable Json.Decode.string)

View file

@ -68,7 +68,7 @@ expectJson toMsg decoder =
Ok values ->
Ok
{ prev =
Dict.get "link" (Debug.log "headers" metadata.headers)
Dict.get "link" metadata.headers
|> Maybe.map links
|> Maybe.andThen (Dict.get "prev")
|> Maybe.andThen Url.fromString