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 `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` ( CREATE TABLE IF NOT EXISTS `sets` (
`set_code` TEXT PRIMARY KEY, `set_code` TEXT PRIMARY KEY,
`name` TEXT NOT NULL `name` TEXT NOT NULL

View file

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

View file

@ -94,6 +94,7 @@ async def advanced_search(
search: tutor.search.Search, search: tutor.search.Search,
limit: int = 10, limit: int = 10,
offset: int = 0, offset: int = 0,
sort_by: str = "rarity",
in_collection: typing.Optional[bool] = None, in_collection: typing.Optional[bool] = None,
) -> typing.List[tutor.models.Card]: ) -> typing.List[tutor.models.Card]:
db.row_factory = aiosqlite.Row db.row_factory = aiosqlite.Row
@ -119,20 +120,18 @@ async def advanced_search(
constraints.append(f"cards.color_identity LIKE :{param}") constraints.append(f"cards.color_identity LIKE :{param}")
params[param] = tutor.models.Color.to_string(criterion.colors) params[param] = tutor.models.Color.to_string(criterion.colors)
if criterion.operator == tutor.search.Operator.lte: if criterion.operator == tutor.search.Operator.lte:
colors = list({str(color) for color in criterion.colors} | {""})
constraints.append( constraints.append(
"({})".format( "({})".format(
" OR ".join( " OR ".join(
[ [
f"cards.color_identity LIKE :{param}_{color}" f"cards.color_identity LIKE :{param}_{color}"
for color in criterion.colors for color in colors
] ]
) )
) )
) )
params.update( params.update({f"{param}_{color}": color for color in colors})
{f"{param}_{color}": str(color) for color in criterion.colors}
)
params[param] = tutor.models.Color.to_string(criterion.colors)
if criterion.operator == tutor.search.Operator.gte: if criterion.operator == tutor.search.Operator.gte:
constraints.append(f"cards.color_identity LIKE :{param}") constraints.append(f"cards.color_identity LIKE :{param}")
params[param] = "%{}%".format( params[param] = "%{}%".format(
@ -160,24 +159,45 @@ async def advanced_search(
constraints.append("copies.id IS NULL") constraints.append("copies.id IS NULL")
joins.append("JOIN sets ON (cards.set_code = sets.set_code)") joins.append("JOIN sets ON (cards.set_code = sets.set_code)")
joins.append("JOIN rarities ON (cards.rarity = rarities.rarity)") 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( query = " ".join(
[ [
"SELECT cards.* FROM cards", "SELECT cards.*, card_prices.* FROM cards",
" ".join(joins), " ".join(joins),
"WHERE" if constraints else "", "WHERE" if constraints else "",
" AND ".join(constraints), " AND ".join(constraints),
"ORDER BY rarities.rarity_ord DESC", "ORDER BY " if orderings else "",
", length(cards.color_identity) DESC", ", ".join(orderings),
", CASE ",
" WHEN length(cards.color_identity) > 0 THEN '0'"
" ELSE cards.color_identity END ASC",
", cards.name ASC",
f"LIMIT {offset},{limit}", f"LIMIT {offset},{limit}",
] ]
) )
logger.debug("Query: %s", (query, params)) logger.debug("Query: %s", (query, params))
cursor = await db.execute(query, params) cursor = await db.execute(query, params)
rows = await cursor.fetchall() 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 [ return [
tutor.models.Card( tutor.models.Card(
scryfall_id=uuid.UUID(row["scryfall_id"]), scryfall_id=uuid.UUID(row["scryfall_id"]),
@ -193,6 +213,11 @@ async def advanced_search(
legalities={}, legalities={},
edhrec_rank=row["edhrec_rank"], edhrec_rank=row["edhrec_rank"],
oracle_text=row["oracle_text"], 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 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: async def store_set(db: aiosqlite.Connection, set_code: str, name: str) -> None:
await db.execute( await db.execute(
"INSERT INTO `sets` (`set_code`, `name`) " "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, "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: except KeyError:
raise TypeError(f"Unknown rarity '{rarity}'") raise TypeError(f"Unknown rarity '{rarity}'")
class Game(enum.Enum): class Game(enum.Enum):
Paper = "paper" Paper = "paper"
Arena = "arena" Arena = "arena"
MTGOnline = "mtgo" MTGOnline = "mtgo"
class Legality(enum.Enum): class Legality(enum.Enum):
Legal = "legal" Legal = "legal"
NotLegal = "not_legal" NotLegal = "not_legal"
Restricted = "restricted" Restricted = "restricted"
Banned = "banned" Banned = "banned"
@dataclasses.dataclass @dataclasses.dataclass
class Card: class Card:
scryfall_id: uuid.UUID scryfall_id: uuid.UUID
@ -74,6 +77,12 @@ class Card:
legalities: typing.Dict[str, Legality] legalities: typing.Dict[str, Legality]
edhrec_rank: typing.Optional[int] = None edhrec_rank: typing.Optional[int] = None
oracle_text: typing.Optional[str] = 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 @dataclasses.dataclass
class CardCopy: class CardCopy:

View file

@ -5,6 +5,9 @@ import tutor.models
def to_card(data: dict) -> tutor.models.Card: 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( return tutor.models.Card(
scryfall_id=data["id"], scryfall_id=data["id"],
name=data["name"], name=data["name"],
@ -26,4 +29,9 @@ def to_card(data: dict) -> tutor.models.Card:
else None else None
), ),
oracle_text=data.get("oracle_text"), 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 json
import typing
import urllib.parse import urllib.parse
import aiosqlite import aiosqlite
@ -59,12 +61,14 @@ class SearchHandler(tornado.web.RequestHandler):
in_collection = self.get_argument("in_collection", None) in_collection = self.get_argument("in_collection", None)
page = max(1, int(self.get_argument("page", 1))) page = max(1, int(self.get_argument("page", 1)))
limit = int(self.get_argument("limit", 10)) limit = int(self.get_argument("limit", 10))
sort_by = self.get_argument("sort_by", "rarity")
search = tutor.search.search.parse(query) search = tutor.search.search.parse(query)
cards = await tutor.database.advanced_search( cards = await tutor.database.advanced_search(
db, db,
search, search,
limit=limit + 1, limit=limit + 1,
offset=limit * (page - 1), offset=limit * (page - 1),
sort_by=sort_by,
in_collection=in_collection in ("yes", "true") in_collection=in_collection in ("yes", "true")
if in_collection if in_collection
else None, else None,
@ -78,6 +82,11 @@ class SearchHandler(tornado.web.RequestHandler):
if has_more: if has_more:
links["next"] = update_args(self.request.full_url(), page=page + 1) links["next"] = update_args(self.request.full_url(), page=page + 1)
self.set_links(**links) 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( self.write(
json.dumps( json.dumps(
[ [
@ -91,6 +100,13 @@ class SearchHandler(tornado.web.RequestHandler):
card.color_identity card.color_identity
), ),
"oracle_text": card.oracle_text, "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] for card in cards[:limit]
] ]

View file

@ -16,6 +16,7 @@ import Element.Input as Input
import Html.Events import Html.Events
import Http import Http
import Json.Decode import Json.Decode
import Maybe.Extra
import Paginated import Paginated
import Spinner import Spinner
import Task import Task
@ -33,6 +34,7 @@ type alias Dimensions =
type alias Criteria = type alias Criteria =
{ query : String { query : String
, sortBy : String
, ownedOnly : Bool , ownedOnly : Bool
} }
@ -62,6 +64,7 @@ type Msg
type CriteriaMsg type CriteriaMsg
= UpdateName String = UpdateName String
| UpdateSortBy String
| UpdateOwnedOnly Bool | UpdateOwnedOnly Bool
@ -117,12 +120,13 @@ manaSpinner =
searchQuery : Criteria -> List Url.Builder.QueryParameter searchQuery : Criteria -> List Url.Builder.QueryParameter
searchQuery criteria = searchQuery criteria =
[ Url.Builder.string "q" criteria.query [ Url.Builder.string "q" criteria.query
, Url.Builder.string "sort_by" criteria.sortBy
, Url.Builder.string "in_collection" , Url.Builder.string "in_collection"
(if criteria.ownedOnly then (if criteria.ownedOnly then
"yes" "yes"
else else
"" "no"
) )
] ]
@ -150,6 +154,15 @@ parseUrl =
Url.Parser.Query.string "q" Url.Parser.Query.string "q"
|> Url.Parser.Query.map (Maybe.withDefault "") |> 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 = inCollection =
Url.Parser.Query.enum "in_collection" Url.Parser.Query.enum "in_collection"
(Dict.fromList (Dict.fromList
@ -161,14 +174,14 @@ parseUrl =
) )
|> Url.Parser.Query.map (Maybe.withDefault True) |> Url.Parser.Query.map (Maybe.withDefault True)
in 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.Url -> Criteria
criteriaFromUrl url = criteriaFromUrl url =
let let
emptyCriteria = emptyCriteria =
{ query = "", ownedOnly = True } { query = "", sortBy = "rarity", ownedOnly = True }
in in
Url.Parser.parse parseUrl url Url.Parser.parse parseUrl url
|> Maybe.withDefault emptyCriteria |> Maybe.withDefault emptyCriteria
@ -207,6 +220,9 @@ updateCriteria msg model =
UpdateName text -> UpdateName text ->
{ model | query = text } { model | query = text }
UpdateSortBy column ->
{ model | sortBy = column }
UpdateOwnedOnly value -> UpdateOwnedOnly value ->
{ model | ownedOnly = value } { model | ownedOnly = value }
@ -217,7 +233,7 @@ update msg model =
UrlChanged url -> UrlChanged url ->
let let
criteria = criteria =
criteriaFromUrl url Debug.log "criteria" <| criteriaFromUrl url
in in
( { model | criteria = criteria }, search criteria ) ( { model | criteria = criteria }, search criteria )
@ -241,19 +257,16 @@ update msg model =
, Cmd.none , Cmd.none
) )
UpdateSortBy _ ->
update Search { model | criteria = newCriteria }
UpdateOwnedOnly _ -> UpdateOwnedOnly _ ->
( { model update Search { model | criteria = newCriteria }
| criteria = newCriteria
, cardPage = toLoading model.cardPage
}
, search newCriteria
)
Search -> Search ->
( { model | cardPage = toLoading model.cardPage } ( { model | cardPage = toLoading model.cardPage }
, Cmd.batch , Cmd.batch
[ search model.criteria [ Browser.Navigation.pushUrl model.navigationKey <|
, Browser.Navigation.pushUrl model.navigationKey <|
Url.Builder.relative [] (searchQuery model.criteria) Url.Builder.relative [] (searchQuery model.criteria)
] ]
) )
@ -373,13 +386,41 @@ viewCardBrowser model =
Nothing -> Nothing ->
E.el [ E.width E.fill ] E.none 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 = 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.column
[ E.spacing 20 [ E.spacing 20
, E.padding 10 , E.padding 10
] ]
<| <|
E.el [ E.centerX ] (viewCard { width = 192, height = 272 } card) 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 ] :: E.paragraph [ Font.heavy, Font.size 24, Font.center ] [ E.text card.name ]
:: List.map (\text -> E.paragraph [ Font.size 16 ] [ E.text text ]) :: List.map (\text -> E.paragraph [ Font.size 16 ] [ E.text text ])
(String.lines card.oracleText) (String.lines card.oracleText)
@ -494,6 +535,15 @@ view model =
{ onPress = Just Search { onPress = Just Search
, label = E.text "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 [] , Input.checkbox []
{ onChange = UpdateCriteria << UpdateOwnedOnly { onChange = UpdateCriteria << UpdateOwnedOnly
, icon = Input.defaultCheckbox , icon = Input.defaultCheckbox

View file

@ -4,12 +4,22 @@ import Json.Decode
import Json.Decode.Pipeline as JDP 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 = type alias Card =
{ scryfallId : String { scryfallId : String
, name : String , name : String
, setCode : String , setCode : String
, rarity : String , rarity : String
, oracleText : String , oracleText : String
, prices : Prices
} }
@ -24,3 +34,14 @@ decode =
(Json.Decode.nullable Json.Decode.string (Json.Decode.nullable Json.Decode.string
|> Json.Decode.map (Maybe.withDefault "") |> 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 values ->
Ok Ok
{ prev = { prev =
Dict.get "link" (Debug.log "headers" metadata.headers) Dict.get "link" metadata.headers
|> Maybe.map links |> Maybe.map links
|> Maybe.andThen (Dict.get "prev") |> Maybe.andThen (Dict.get "prev")
|> Maybe.andThen Url.fromString |> Maybe.andThen Url.fromString