Add price support
This commit is contained in:
parent
eb61663888
commit
0140c9c889
9 changed files with 206 additions and 26 deletions
11
tables.sql
11
tables.sql
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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},
|
||||
)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"),
|
||||
)
|
||||
|
|
|
@ -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]
|
||||
]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue