Compare commits
16 Commits
8f35cedb4a
...
ui
Author | SHA1 | Date | |
---|---|---|---|
82fd1cb2da | |||
77d6fd6e29 | |||
7bc64115f2 | |||
fa089adb53 | |||
dca11b0ede | |||
f2c2b69d63 | |||
5c85411c69 | |||
11aa4cda16 | |||
c9bba8a26e | |||
d75e20ff2c | |||
56ba750aad | |||
210a033695 | |||
d8ae45c025 | |||
34eac3d954 | |||
6178fdd15d | |||
03b43ce3ab |
@ -67,7 +67,8 @@ def run_migrations_online() -> None:
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
@ -77,4 +78,4 @@ def run_migrations_online() -> None:
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
run_migrations_online()
|
36
alembic/versions/0f534237fc90_i_literally_hate_sql.py
Normal file
36
alembic/versions/0f534237fc90_i_literally_hate_sql.py
Normal file
@ -0,0 +1,36 @@
|
||||
"""i literally hate sql
|
||||
|
||||
Revision ID: 0f534237fc90
|
||||
Revises: cf61f006db46
|
||||
Create Date: 2025-04-25 16:59:07.177958
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0f534237fc90'
|
||||
down_revision: Union[str, None] = 'cf61f006db46'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint('uq_sku_mtgjson_uuid', 'mtgjson_skus', type_='unique')
|
||||
op.drop_index('ix_mtgjson_skus_mtgjson_uuid', table_name='mtgjson_skus')
|
||||
op.create_index(op.f('ix_mtgjson_skus_mtgjson_uuid'), 'mtgjson_skus', ['mtgjson_uuid'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_mtgjson_skus_mtgjson_uuid'), table_name='mtgjson_skus')
|
||||
op.create_index('ix_mtgjson_skus_mtgjson_uuid', 'mtgjson_skus', ['mtgjson_uuid'], unique=True)
|
||||
op.create_unique_constraint('uq_sku_mtgjson_uuid', 'mtgjson_skus', ['mtgjson_uuid'])
|
||||
# ### end Alembic commands ###
|
40
alembic/versions/236605bcac6e_asdf.py
Normal file
40
alembic/versions/236605bcac6e_asdf.py
Normal file
@ -0,0 +1,40 @@
|
||||
"""asdf
|
||||
|
||||
Revision ID: 236605bcac6e
|
||||
Revises: d13600612a8f
|
||||
Create Date: 2025-04-28 21:44:28.030202
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '236605bcac6e'
|
||||
down_revision: Union[str, None] = 'd13600612a8f'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('marketplace_listings', sa.Column('recommended_price_id', sa.Integer(), nullable=True))
|
||||
op.add_column('marketplace_listings', sa.Column('listed_price_id', sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(None, 'marketplace_listings', 'pricing_events', ['recommended_price_id'], ['id'])
|
||||
op.create_foreign_key(None, 'marketplace_listings', 'pricing_events', ['listed_price_id'], ['id'])
|
||||
op.drop_column('marketplace_listings', 'listed_price')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('marketplace_listings', sa.Column('listed_price', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True))
|
||||
op.drop_constraint(None, 'marketplace_listings', type_='foreignkey')
|
||||
op.drop_constraint(None, 'marketplace_listings', type_='foreignkey')
|
||||
op.drop_column('marketplace_listings', 'listed_price_id')
|
||||
op.drop_column('marketplace_listings', 'recommended_price_id')
|
||||
# ### end Alembic commands ###
|
@ -1,369 +0,0 @@
|
||||
"""i hate alembic so goddamn much
|
||||
|
||||
Revision ID: 479003fbead7
|
||||
Revises:
|
||||
Create Date: 2025-04-17 12:08:13.714276
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '479003fbead7'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('cards',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('rarity', sa.String(), nullable=True),
|
||||
sa.Column('set_name', sa.String(), nullable=True),
|
||||
sa.Column('price', sa.Float(), nullable=True),
|
||||
sa.Column('quantity', sa.Integer(), nullable=True),
|
||||
sa.Column('tcgplayer_sku', sa.String(), nullable=True),
|
||||
sa.Column('product_line', sa.String(), nullable=True),
|
||||
sa.Column('product_name', sa.String(), nullable=True),
|
||||
sa.Column('title', sa.String(), nullable=True),
|
||||
sa.Column('number', sa.String(), nullable=True),
|
||||
sa.Column('condition', sa.String(), nullable=True),
|
||||
sa.Column('tcg_market_price', sa.Float(), nullable=True),
|
||||
sa.Column('tcg_direct_low', sa.Float(), nullable=True),
|
||||
sa.Column('tcg_low_price_with_shipping', sa.Float(), nullable=True),
|
||||
sa.Column('tcg_low_price', sa.Float(), nullable=True),
|
||||
sa.Column('total_quantity', sa.Integer(), nullable=True),
|
||||
sa.Column('add_to_quantity', sa.Integer(), nullable=True),
|
||||
sa.Column('tcg_marketplace_price', sa.Float(), nullable=True),
|
||||
sa.Column('photo_url', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_cards_id'), 'cards', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_cards_name'), 'cards', ['name'], unique=False)
|
||||
op.create_index(op.f('ix_cards_set_name'), 'cards', ['set_name'], unique=False)
|
||||
op.create_index(op.f('ix_cards_tcgplayer_sku'), 'cards', ['tcgplayer_sku'], unique=True)
|
||||
op.create_table('files',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('file_type', sa.String(), nullable=True),
|
||||
sa.Column('content_type', sa.String(), nullable=True),
|
||||
sa.Column('path', sa.String(), nullable=True),
|
||||
sa.Column('size', sa.Integer(), nullable=True),
|
||||
sa.Column('file_metadata', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_files_id'), 'files', ['id'], unique=False)
|
||||
op.create_table('games',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('description', sa.String(), nullable=True),
|
||||
sa.Column('image_url', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_games_id'), 'games', ['id'], unique=False)
|
||||
op.create_table('inventory',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('tcgplayer_id', sa.String(), nullable=True),
|
||||
sa.Column('product_line', sa.String(), nullable=True),
|
||||
sa.Column('set_name', sa.String(), nullable=True),
|
||||
sa.Column('product_name', sa.String(), nullable=True),
|
||||
sa.Column('title', sa.String(), nullable=True),
|
||||
sa.Column('number', sa.String(), nullable=True),
|
||||
sa.Column('rarity', sa.String(), nullable=True),
|
||||
sa.Column('condition', sa.String(), nullable=True),
|
||||
sa.Column('tcg_market_price', sa.Float(), nullable=True),
|
||||
sa.Column('tcg_direct_low', sa.Float(), nullable=True),
|
||||
sa.Column('tcg_low_price_with_shipping', sa.Float(), nullable=True),
|
||||
sa.Column('tcg_low_price', sa.Float(), nullable=True),
|
||||
sa.Column('total_quantity', sa.Integer(), nullable=True),
|
||||
sa.Column('add_to_quantity', sa.Integer(), nullable=True),
|
||||
sa.Column('tcg_marketplace_price', sa.Float(), nullable=True),
|
||||
sa.Column('photo_url', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_inventory_id'), 'inventory', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_inventory_tcgplayer_id'), 'inventory', ['tcgplayer_id'], unique=True)
|
||||
op.create_table('mtgjson_cards',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('card_id', sa.String(), nullable=True),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('set_code', sa.String(), nullable=True),
|
||||
sa.Column('uuid', sa.String(), nullable=True),
|
||||
sa.Column('abu_id', sa.String(), nullable=True),
|
||||
sa.Column('card_kingdom_etched_id', sa.String(), nullable=True),
|
||||
sa.Column('card_kingdom_foil_id', sa.String(), nullable=True),
|
||||
sa.Column('card_kingdom_id', sa.String(), nullable=True),
|
||||
sa.Column('cardsphere_id', sa.String(), nullable=True),
|
||||
sa.Column('cardsphere_foil_id', sa.String(), nullable=True),
|
||||
sa.Column('cardtrader_id', sa.String(), nullable=True),
|
||||
sa.Column('csi_id', sa.String(), nullable=True),
|
||||
sa.Column('mcm_id', sa.String(), nullable=True),
|
||||
sa.Column('mcm_meta_id', sa.String(), nullable=True),
|
||||
sa.Column('miniaturemarket_id', sa.String(), nullable=True),
|
||||
sa.Column('mtg_arena_id', sa.String(), nullable=True),
|
||||
sa.Column('mtgjson_foil_version_id', sa.String(), nullable=True),
|
||||
sa.Column('mtgjson_non_foil_version_id', sa.String(), nullable=True),
|
||||
sa.Column('mtgjson_v4_id', sa.String(), nullable=True),
|
||||
sa.Column('mtgo_foil_id', sa.String(), nullable=True),
|
||||
sa.Column('mtgo_id', sa.String(), nullable=True),
|
||||
sa.Column('multiverse_id', sa.String(), nullable=True),
|
||||
sa.Column('scg_id', sa.String(), nullable=True),
|
||||
sa.Column('scryfall_id', sa.String(), nullable=True),
|
||||
sa.Column('scryfall_card_back_id', sa.String(), nullable=True),
|
||||
sa.Column('scryfall_oracle_id', sa.String(), nullable=True),
|
||||
sa.Column('scryfall_illustration_id', sa.String(), nullable=True),
|
||||
sa.Column('tcgplayer_product_id', sa.String(), nullable=True),
|
||||
sa.Column('tcgplayer_etched_product_id', sa.String(), nullable=True),
|
||||
sa.Column('tnt_id', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_mtgjson_cards_card_id'), 'mtgjson_cards', ['card_id'], unique=True)
|
||||
op.create_index(op.f('ix_mtgjson_cards_id'), 'mtgjson_cards', ['id'], unique=False)
|
||||
op.create_table('mtgjson_skus',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('sku_id', sa.String(), nullable=True),
|
||||
sa.Column('product_id', sa.String(), nullable=True),
|
||||
sa.Column('condition', sa.String(), nullable=True),
|
||||
sa.Column('finish', sa.String(), nullable=True),
|
||||
sa.Column('language', sa.String(), nullable=True),
|
||||
sa.Column('printing', sa.String(), nullable=True),
|
||||
sa.Column('card_id', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_mtgjson_skus_id'), 'mtgjson_skus', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_mtgjson_skus_product_id'), 'mtgjson_skus', ['product_id'], unique=False)
|
||||
op.create_index(op.f('ix_mtgjson_skus_sku_id'), 'mtgjson_skus', ['sku_id'], unique=False)
|
||||
op.create_table('tcgplayer_categories',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('category_id', sa.Integer(), nullable=True),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('display_name', sa.String(), nullable=True),
|
||||
sa.Column('seo_category_name', sa.String(), nullable=True),
|
||||
sa.Column('category_description', sa.String(), nullable=True),
|
||||
sa.Column('category_page_title', sa.String(), nullable=True),
|
||||
sa.Column('sealed_label', sa.String(), nullable=True),
|
||||
sa.Column('non_sealed_label', sa.String(), nullable=True),
|
||||
sa.Column('condition_guide_url', sa.String(), nullable=True),
|
||||
sa.Column('is_scannable', sa.Boolean(), nullable=True),
|
||||
sa.Column('popularity', sa.Integer(), nullable=True),
|
||||
sa.Column('is_direct', sa.Boolean(), nullable=True),
|
||||
sa.Column('modified_on', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tcgplayer_categories_category_id'), 'tcgplayer_categories', ['category_id'], unique=True)
|
||||
op.create_index(op.f('ix_tcgplayer_categories_id'), 'tcgplayer_categories', ['id'], unique=False)
|
||||
op.create_table('tcgplayer_groups',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('group_id', sa.Integer(), nullable=True),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('abbreviation', sa.String(), nullable=True),
|
||||
sa.Column('is_supplemental', sa.Boolean(), nullable=True),
|
||||
sa.Column('published_on', sa.DateTime(), nullable=True),
|
||||
sa.Column('modified_on', sa.DateTime(), nullable=True),
|
||||
sa.Column('category_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tcgplayer_groups_group_id'), 'tcgplayer_groups', ['group_id'], unique=True)
|
||||
op.create_index(op.f('ix_tcgplayer_groups_id'), 'tcgplayer_groups', ['id'], unique=False)
|
||||
op.create_table('tcgplayer_order_products',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('order_number', sa.String(), nullable=True),
|
||||
sa.Column('product_name', sa.String(), nullable=True),
|
||||
sa.Column('unit_price', sa.Float(), nullable=True),
|
||||
sa.Column('extended_price', sa.Float(), nullable=True),
|
||||
sa.Column('quantity', sa.Integer(), nullable=True),
|
||||
sa.Column('url', sa.String(), nullable=True),
|
||||
sa.Column('product_id', sa.String(), nullable=True),
|
||||
sa.Column('sku_id', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tcgplayer_order_products_id'), 'tcgplayer_order_products', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_tcgplayer_order_products_order_number'), 'tcgplayer_order_products', ['order_number'], unique=False)
|
||||
op.create_table('tcgplayer_order_refunds',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('order_number', sa.String(), nullable=True),
|
||||
sa.Column('refund_created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('type', sa.String(), nullable=True),
|
||||
sa.Column('amount', sa.Float(), nullable=True),
|
||||
sa.Column('description', sa.String(), nullable=True),
|
||||
sa.Column('origin', sa.String(), nullable=True),
|
||||
sa.Column('shipping_amount', sa.Float(), nullable=True),
|
||||
sa.Column('products', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tcgplayer_order_refunds_id'), 'tcgplayer_order_refunds', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_tcgplayer_order_refunds_order_number'), 'tcgplayer_order_refunds', ['order_number'], unique=False)
|
||||
op.create_table('tcgplayer_order_transactions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('order_number', sa.String(), nullable=True),
|
||||
sa.Column('product_amount', sa.Float(), nullable=True),
|
||||
sa.Column('shipping_amount', sa.Float(), nullable=True),
|
||||
sa.Column('gross_amount', sa.Float(), nullable=True),
|
||||
sa.Column('fee_amount', sa.Float(), nullable=True),
|
||||
sa.Column('net_amount', sa.Float(), nullable=True),
|
||||
sa.Column('direct_fee_amount', sa.Float(), nullable=True),
|
||||
sa.Column('taxes', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tcgplayer_order_transactions_id'), 'tcgplayer_order_transactions', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_tcgplayer_order_transactions_order_number'), 'tcgplayer_order_transactions', ['order_number'], unique=False)
|
||||
op.create_table('tcgplayer_orders',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('order_number', sa.String(), nullable=True),
|
||||
sa.Column('order_created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('status', sa.String(), nullable=True),
|
||||
sa.Column('channel', sa.String(), nullable=True),
|
||||
sa.Column('fulfillment', sa.String(), nullable=True),
|
||||
sa.Column('seller_name', sa.String(), nullable=True),
|
||||
sa.Column('buyer_name', sa.String(), nullable=True),
|
||||
sa.Column('payment_type', sa.String(), nullable=True),
|
||||
sa.Column('pickup_status', sa.String(), nullable=True),
|
||||
sa.Column('shipping_type', sa.String(), nullable=True),
|
||||
sa.Column('estimated_delivery_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('recipient_name', sa.String(), nullable=True),
|
||||
sa.Column('address_line_1', sa.String(), nullable=True),
|
||||
sa.Column('address_line_2', sa.String(), nullable=True),
|
||||
sa.Column('city', sa.String(), nullable=True),
|
||||
sa.Column('state', sa.String(), nullable=True),
|
||||
sa.Column('zip_code', sa.String(), nullable=True),
|
||||
sa.Column('country', sa.String(), nullable=True),
|
||||
sa.Column('tracking_numbers', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tcgplayer_orders_id'), 'tcgplayer_orders', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_tcgplayer_orders_order_number'), 'tcgplayer_orders', ['order_number'], unique=False)
|
||||
op.create_table('boxes',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('product_id', sa.Integer(), nullable=True),
|
||||
sa.Column('type', sa.String(), nullable=True),
|
||||
sa.Column('set_code', sa.String(), nullable=True),
|
||||
sa.Column('sku', sa.Integer(), nullable=True),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('game_id', sa.Integer(), nullable=True),
|
||||
sa.Column('expected_number_of_cards', sa.Integer(), nullable=True),
|
||||
sa.Column('description', sa.String(), nullable=True),
|
||||
sa.Column('image_url', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['game_id'], ['games.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_boxes_id'), 'boxes', ['id'], unique=False)
|
||||
op.create_table('tcgplayer_products',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('product_id', sa.Integer(), nullable=True),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('clean_name', sa.String(), nullable=True),
|
||||
sa.Column('image_url', sa.String(), nullable=True),
|
||||
sa.Column('category_id', sa.Integer(), nullable=True),
|
||||
sa.Column('group_id', sa.Integer(), nullable=True),
|
||||
sa.Column('url', sa.String(), nullable=True),
|
||||
sa.Column('modified_on', sa.DateTime(), nullable=True),
|
||||
sa.Column('image_count', sa.Integer(), nullable=True),
|
||||
sa.Column('ext_rarity', sa.String(), nullable=True),
|
||||
sa.Column('ext_number', sa.String(), nullable=True),
|
||||
sa.Column('low_price', sa.Float(), nullable=True),
|
||||
sa.Column('mid_price', sa.Float(), nullable=True),
|
||||
sa.Column('high_price', sa.Float(), nullable=True),
|
||||
sa.Column('market_price', sa.Float(), nullable=True),
|
||||
sa.Column('direct_low_price', sa.Float(), nullable=True),
|
||||
sa.Column('sub_type_name', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['group_id'], ['tcgplayer_groups.group_id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tcgplayer_products_id'), 'tcgplayer_products', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_tcgplayer_products_product_id'), 'tcgplayer_products', ['product_id'], unique=False)
|
||||
op.create_table('open_boxes',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('box_id', sa.Integer(), nullable=True),
|
||||
sa.Column('number_of_cards', sa.Integer(), nullable=True),
|
||||
sa.Column('date_opened', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['box_id'], ['boxes.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_open_boxes_id'), 'open_boxes', ['id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_open_boxes_id'), table_name='open_boxes')
|
||||
op.drop_table('open_boxes')
|
||||
op.drop_index(op.f('ix_tcgplayer_products_product_id'), table_name='tcgplayer_products')
|
||||
op.drop_index(op.f('ix_tcgplayer_products_id'), table_name='tcgplayer_products')
|
||||
op.drop_table('tcgplayer_products')
|
||||
op.drop_index(op.f('ix_boxes_id'), table_name='boxes')
|
||||
op.drop_table('boxes')
|
||||
op.drop_index(op.f('ix_tcgplayer_orders_order_number'), table_name='tcgplayer_orders')
|
||||
op.drop_index(op.f('ix_tcgplayer_orders_id'), table_name='tcgplayer_orders')
|
||||
op.drop_table('tcgplayer_orders')
|
||||
op.drop_index(op.f('ix_tcgplayer_order_transactions_order_number'), table_name='tcgplayer_order_transactions')
|
||||
op.drop_index(op.f('ix_tcgplayer_order_transactions_id'), table_name='tcgplayer_order_transactions')
|
||||
op.drop_table('tcgplayer_order_transactions')
|
||||
op.drop_index(op.f('ix_tcgplayer_order_refunds_order_number'), table_name='tcgplayer_order_refunds')
|
||||
op.drop_index(op.f('ix_tcgplayer_order_refunds_id'), table_name='tcgplayer_order_refunds')
|
||||
op.drop_table('tcgplayer_order_refunds')
|
||||
op.drop_index(op.f('ix_tcgplayer_order_products_order_number'), table_name='tcgplayer_order_products')
|
||||
op.drop_index(op.f('ix_tcgplayer_order_products_id'), table_name='tcgplayer_order_products')
|
||||
op.drop_table('tcgplayer_order_products')
|
||||
op.drop_index(op.f('ix_tcgplayer_groups_id'), table_name='tcgplayer_groups')
|
||||
op.drop_index(op.f('ix_tcgplayer_groups_group_id'), table_name='tcgplayer_groups')
|
||||
op.drop_table('tcgplayer_groups')
|
||||
op.drop_index(op.f('ix_tcgplayer_categories_id'), table_name='tcgplayer_categories')
|
||||
op.drop_index(op.f('ix_tcgplayer_categories_category_id'), table_name='tcgplayer_categories')
|
||||
op.drop_table('tcgplayer_categories')
|
||||
op.drop_index(op.f('ix_mtgjson_skus_sku_id'), table_name='mtgjson_skus')
|
||||
op.drop_index(op.f('ix_mtgjson_skus_product_id'), table_name='mtgjson_skus')
|
||||
op.drop_index(op.f('ix_mtgjson_skus_id'), table_name='mtgjson_skus')
|
||||
op.drop_table('mtgjson_skus')
|
||||
op.drop_index(op.f('ix_mtgjson_cards_id'), table_name='mtgjson_cards')
|
||||
op.drop_index(op.f('ix_mtgjson_cards_card_id'), table_name='mtgjson_cards')
|
||||
op.drop_table('mtgjson_cards')
|
||||
op.drop_index(op.f('ix_inventory_tcgplayer_id'), table_name='inventory')
|
||||
op.drop_index(op.f('ix_inventory_id'), table_name='inventory')
|
||||
op.drop_table('inventory')
|
||||
op.drop_index(op.f('ix_games_id'), table_name='games')
|
||||
op.drop_table('games')
|
||||
op.drop_index(op.f('ix_files_id'), table_name='files')
|
||||
op.drop_table('files')
|
||||
op.drop_index(op.f('ix_cards_tcgplayer_sku'), table_name='cards')
|
||||
op.drop_index(op.f('ix_cards_set_name'), table_name='cards')
|
||||
op.drop_index(op.f('ix_cards_name'), table_name='cards')
|
||||
op.drop_index(op.f('ix_cards_id'), table_name='cards')
|
||||
op.drop_table('cards')
|
||||
# ### end Alembic commands ###
|
32
alembic/versions/62eee00bae8e_b.py
Normal file
32
alembic/versions/62eee00bae8e_b.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""b
|
||||
|
||||
Revision ID: 62eee00bae8e
|
||||
Revises: 0f534237fc90
|
||||
Create Date: 2025-04-28 11:01:28.564264
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '62eee00bae8e'
|
||||
down_revision: Union[str, None] = '0f534237fc90'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_index('idx_product_subtype', 'tcgplayer_products', ['tcgplayer_product_id', 'normalized_sub_type_name'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index('idx_product_subtype', table_name='tcgplayer_products')
|
||||
# ### end Alembic commands ###
|
@ -0,0 +1,553 @@
|
||||
"""alembic is actually behaving so this message will be nice :)
|
||||
|
||||
Revision ID: cf61f006db46
|
||||
Revises:
|
||||
Create Date: 2025-04-25 14:34:28.206737
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'cf61f006db46'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('critical_error_logs',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('error_message', sa.String(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_critical_error_logs_id'), 'critical_error_logs', ['id'], unique=False)
|
||||
op.create_table('customers',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_customers_id'), 'customers', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_customers_name'), 'customers', ['name'], unique=False)
|
||||
op.create_table('files',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('file_type', sa.String(), nullable=True),
|
||||
sa.Column('content_type', sa.String(), nullable=True),
|
||||
sa.Column('path', sa.String(), nullable=True),
|
||||
sa.Column('size', sa.Integer(), nullable=True),
|
||||
sa.Column('file_metadata', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_files_id'), 'files', ['id'], unique=False)
|
||||
op.create_table('marketplaces',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_marketplaces_id'), 'marketplaces', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_marketplaces_name'), 'marketplaces', ['name'], unique=False)
|
||||
op.create_table('most_recent_tcgplayer_price',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('product_id', sa.Integer(), nullable=False),
|
||||
sa.Column('sub_type_name', sa.String(), nullable=True),
|
||||
sa.Column('date', sa.DateTime(), nullable=False),
|
||||
sa.Column('low_price', sa.Float(), nullable=True),
|
||||
sa.Column('mid_price', sa.Float(), nullable=True),
|
||||
sa.Column('high_price', sa.Float(), nullable=True),
|
||||
sa.Column('market_price', sa.Float(), nullable=True),
|
||||
sa.Column('direct_low_price', sa.Float(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_most_recent_price_product_subtype', 'most_recent_tcgplayer_price', ['product_id', 'sub_type_name'], unique=True)
|
||||
op.create_index(op.f('ix_most_recent_tcgplayer_price_product_id'), 'most_recent_tcgplayer_price', ['product_id'], unique=False)
|
||||
op.create_index(op.f('ix_most_recent_tcgplayer_price_sub_type_name'), 'most_recent_tcgplayer_price', ['sub_type_name'], unique=False)
|
||||
op.create_table('mtgjson_cards',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('mtgjson_uuid', sa.String(), nullable=True),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('set_code', sa.String(), nullable=True),
|
||||
sa.Column('abu_id', sa.String(), nullable=True),
|
||||
sa.Column('card_kingdom_etched_id', sa.String(), nullable=True),
|
||||
sa.Column('card_kingdom_foil_id', sa.String(), nullable=True),
|
||||
sa.Column('card_kingdom_id', sa.String(), nullable=True),
|
||||
sa.Column('cardsphere_id', sa.String(), nullable=True),
|
||||
sa.Column('cardsphere_foil_id', sa.String(), nullable=True),
|
||||
sa.Column('cardtrader_id', sa.String(), nullable=True),
|
||||
sa.Column('csi_id', sa.String(), nullable=True),
|
||||
sa.Column('mcm_id', sa.String(), nullable=True),
|
||||
sa.Column('mcm_meta_id', sa.String(), nullable=True),
|
||||
sa.Column('miniaturemarket_id', sa.String(), nullable=True),
|
||||
sa.Column('mtg_arena_id', sa.String(), nullable=True),
|
||||
sa.Column('mtgjson_foil_version_id', sa.String(), nullable=True),
|
||||
sa.Column('mtgjson_non_foil_version_id', sa.String(), nullable=True),
|
||||
sa.Column('mtgjson_v4_id', sa.String(), nullable=True),
|
||||
sa.Column('mtgo_foil_id', sa.String(), nullable=True),
|
||||
sa.Column('mtgo_id', sa.String(), nullable=True),
|
||||
sa.Column('multiverse_id', sa.String(), nullable=True),
|
||||
sa.Column('scg_id', sa.String(), nullable=True),
|
||||
sa.Column('scryfall_id', sa.String(), nullable=True),
|
||||
sa.Column('scryfall_card_back_id', sa.String(), nullable=True),
|
||||
sa.Column('scryfall_oracle_id', sa.String(), nullable=True),
|
||||
sa.Column('scryfall_illustration_id', sa.String(), nullable=True),
|
||||
sa.Column('tcgplayer_product_id', sa.Integer(), nullable=True),
|
||||
sa.Column('tcgplayer_etched_product_id', sa.Integer(), nullable=True),
|
||||
sa.Column('tnt_id', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['mtgjson_uuid'], ['mtgjson_cards.mtgjson_uuid'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('mtgjson_uuid', name='uq_card_mtgjson_uuid')
|
||||
)
|
||||
op.create_index(op.f('ix_mtgjson_cards_id'), 'mtgjson_cards', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_mtgjson_cards_mtgjson_uuid'), 'mtgjson_cards', ['mtgjson_uuid'], unique=True)
|
||||
op.create_table('sealed_expected_values',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('tcgplayer_product_id', sa.Integer(), nullable=False),
|
||||
sa.Column('expected_value', sa.Float(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_sealed_expected_value_product_id_deleted_at', 'sealed_expected_values', ['tcgplayer_product_id', 'deleted_at'], unique=True)
|
||||
op.create_index(op.f('ix_sealed_expected_values_id'), 'sealed_expected_values', ['id'], unique=False)
|
||||
op.create_table('tcgplayer_categories',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('category_id', sa.Integer(), nullable=True),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('display_name', sa.String(), nullable=True),
|
||||
sa.Column('seo_category_name', sa.String(), nullable=True),
|
||||
sa.Column('category_description', sa.String(), nullable=True),
|
||||
sa.Column('category_page_title', sa.String(), nullable=True),
|
||||
sa.Column('sealed_label', sa.String(), nullable=True),
|
||||
sa.Column('non_sealed_label', sa.String(), nullable=True),
|
||||
sa.Column('condition_guide_url', sa.String(), nullable=True),
|
||||
sa.Column('is_scannable', sa.Boolean(), nullable=True),
|
||||
sa.Column('popularity', sa.Integer(), nullable=True),
|
||||
sa.Column('is_direct', sa.Boolean(), nullable=True),
|
||||
sa.Column('modified_on', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tcgplayer_categories_category_id'), 'tcgplayer_categories', ['category_id'], unique=True)
|
||||
op.create_index(op.f('ix_tcgplayer_categories_id'), 'tcgplayer_categories', ['id'], unique=False)
|
||||
op.create_table('tcgplayer_order_products',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('order_number', sa.String(), nullable=True),
|
||||
sa.Column('product_name', sa.String(), nullable=True),
|
||||
sa.Column('unit_price', sa.Float(), nullable=True),
|
||||
sa.Column('extended_price', sa.Float(), nullable=True),
|
||||
sa.Column('quantity', sa.Integer(), nullable=True),
|
||||
sa.Column('url', sa.String(), nullable=True),
|
||||
sa.Column('product_id', sa.Integer(), nullable=True),
|
||||
sa.Column('sku_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tcgplayer_order_products_id'), 'tcgplayer_order_products', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_tcgplayer_order_products_order_number'), 'tcgplayer_order_products', ['order_number'], unique=False)
|
||||
op.create_table('tcgplayer_order_refunds',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('order_number', sa.String(), nullable=True),
|
||||
sa.Column('refund_created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('type', sa.String(), nullable=True),
|
||||
sa.Column('amount', sa.Float(), nullable=True),
|
||||
sa.Column('description', sa.String(), nullable=True),
|
||||
sa.Column('origin', sa.String(), nullable=True),
|
||||
sa.Column('shipping_amount', sa.Float(), nullable=True),
|
||||
sa.Column('products', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tcgplayer_order_refunds_id'), 'tcgplayer_order_refunds', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_tcgplayer_order_refunds_order_number'), 'tcgplayer_order_refunds', ['order_number'], unique=False)
|
||||
op.create_table('tcgplayer_order_transactions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('order_number', sa.String(), nullable=True),
|
||||
sa.Column('product_amount', sa.Float(), nullable=True),
|
||||
sa.Column('shipping_amount', sa.Float(), nullable=True),
|
||||
sa.Column('gross_amount', sa.Float(), nullable=True),
|
||||
sa.Column('fee_amount', sa.Float(), nullable=True),
|
||||
sa.Column('net_amount', sa.Float(), nullable=True),
|
||||
sa.Column('direct_fee_amount', sa.Float(), nullable=True),
|
||||
sa.Column('taxes', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tcgplayer_order_transactions_id'), 'tcgplayer_order_transactions', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_tcgplayer_order_transactions_order_number'), 'tcgplayer_order_transactions', ['order_number'], unique=False)
|
||||
op.create_table('tcgplayer_orders',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('order_number', sa.String(), nullable=True),
|
||||
sa.Column('order_created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('status', sa.String(), nullable=True),
|
||||
sa.Column('channel', sa.String(), nullable=True),
|
||||
sa.Column('fulfillment', sa.String(), nullable=True),
|
||||
sa.Column('seller_name', sa.String(), nullable=True),
|
||||
sa.Column('buyer_name', sa.String(), nullable=True),
|
||||
sa.Column('payment_type', sa.String(), nullable=True),
|
||||
sa.Column('pickup_status', sa.String(), nullable=True),
|
||||
sa.Column('shipping_type', sa.String(), nullable=True),
|
||||
sa.Column('estimated_delivery_date', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('recipient_name', sa.String(), nullable=True),
|
||||
sa.Column('address_line_1', sa.String(), nullable=True),
|
||||
sa.Column('address_line_2', sa.String(), nullable=True),
|
||||
sa.Column('city', sa.String(), nullable=True),
|
||||
sa.Column('state', sa.String(), nullable=True),
|
||||
sa.Column('zip_code', sa.String(), nullable=True),
|
||||
sa.Column('country', sa.String(), nullable=True),
|
||||
sa.Column('tracking_numbers', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tcgplayer_orders_id'), 'tcgplayer_orders', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_tcgplayer_orders_order_number'), 'tcgplayer_orders', ['order_number'], unique=False)
|
||||
op.create_table('tcgplayer_price_history',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('product_id', sa.Integer(), nullable=False),
|
||||
sa.Column('sub_type_name', sa.String(), nullable=True),
|
||||
sa.Column('date', sa.DateTime(), nullable=True),
|
||||
sa.Column('low_price', sa.Float(), nullable=True),
|
||||
sa.Column('mid_price', sa.Float(), nullable=True),
|
||||
sa.Column('high_price', sa.Float(), nullable=True),
|
||||
sa.Column('market_price', sa.Float(), nullable=True),
|
||||
sa.Column('direct_low_price', sa.Float(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_price_history_product_subtype_date', 'tcgplayer_price_history', ['product_id', 'sub_type_name', 'date'], unique=False)
|
||||
op.create_index(op.f('ix_tcgplayer_price_history_date'), 'tcgplayer_price_history', ['date'], unique=False)
|
||||
op.create_index(op.f('ix_tcgplayer_price_history_id'), 'tcgplayer_price_history', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_tcgplayer_price_history_product_id'), 'tcgplayer_price_history', ['product_id'], unique=False)
|
||||
op.create_index(op.f('ix_tcgplayer_price_history_sub_type_name'), 'tcgplayer_price_history', ['sub_type_name'], unique=False)
|
||||
op.create_table('vendors',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_vendors_id'), 'vendors', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_vendors_name'), 'vendors', ['name'], unique=False)
|
||||
op.create_table('manabox_import_staging',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('file_id', sa.Integer(), nullable=True),
|
||||
sa.Column('tcgplayer_product_id', sa.Integer(), nullable=True),
|
||||
sa.Column('tcgplayer_sku_id', sa.Integer(), nullable=True),
|
||||
sa.Column('quantity', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['file_id'], ['files.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('tcgplayer_groups',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('group_id', sa.Integer(), nullable=True),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('abbreviation', sa.String(), nullable=True),
|
||||
sa.Column('is_supplemental', sa.Boolean(), nullable=True),
|
||||
sa.Column('published_on', sa.DateTime(), nullable=True),
|
||||
sa.Column('modified_on', sa.DateTime(), nullable=True),
|
||||
sa.Column('category_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['category_id'], ['tcgplayer_categories.category_id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tcgplayer_groups_group_id'), 'tcgplayer_groups', ['group_id'], unique=True)
|
||||
op.create_index(op.f('ix_tcgplayer_groups_id'), 'tcgplayer_groups', ['id'], unique=False)
|
||||
op.create_table('transactions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('vendor_id', sa.Integer(), nullable=True),
|
||||
sa.Column('customer_id', sa.Integer(), nullable=True),
|
||||
sa.Column('marketplace_id', sa.Integer(), nullable=True),
|
||||
sa.Column('transaction_type', sa.String(), nullable=True),
|
||||
sa.Column('transaction_date', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('transaction_total_amount', sa.Float(), nullable=True),
|
||||
sa.Column('transaction_notes', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['customer_id'], ['customers.id'], ),
|
||||
sa.ForeignKeyConstraint(['marketplace_id'], ['marketplaces.id'], ),
|
||||
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_transactions_id'), 'transactions', ['id'], unique=False)
|
||||
op.create_table('tcgplayer_products',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('tcgplayer_product_id', sa.Integer(), nullable=False),
|
||||
sa.Column('normalized_sub_type_name', sa.String(), nullable=False),
|
||||
sa.Column('sub_type_name', sa.String(), nullable=True),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('clean_name', sa.String(), nullable=True),
|
||||
sa.Column('image_url', sa.String(), nullable=True),
|
||||
sa.Column('category_id', sa.Integer(), nullable=True),
|
||||
sa.Column('group_id', sa.Integer(), nullable=True),
|
||||
sa.Column('url', sa.String(), nullable=True),
|
||||
sa.Column('modified_on', sa.DateTime(), nullable=True),
|
||||
sa.Column('image_count', sa.Integer(), nullable=True),
|
||||
sa.Column('ext_rarity', sa.String(), nullable=True),
|
||||
sa.Column('ext_subtype', sa.String(), nullable=True),
|
||||
sa.Column('ext_oracle_text', sa.String(), nullable=True),
|
||||
sa.Column('ext_number', sa.String(), nullable=True),
|
||||
sa.Column('low_price', sa.Float(), nullable=True),
|
||||
sa.Column('mid_price', sa.Float(), nullable=True),
|
||||
sa.Column('high_price', sa.Float(), nullable=True),
|
||||
sa.Column('market_price', sa.Float(), nullable=True),
|
||||
sa.Column('direct_low_price', sa.Float(), nullable=True),
|
||||
sa.Column('ext_power', sa.String(), nullable=True),
|
||||
sa.Column('ext_toughness', sa.String(), nullable=True),
|
||||
sa.Column('ext_flavor_text', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['category_id'], ['tcgplayer_categories.category_id'], ),
|
||||
sa.ForeignKeyConstraint(['group_id'], ['tcgplayer_groups.group_id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('tcgplayer_product_id', 'normalized_sub_type_name', name='uq_product_subtype')
|
||||
)
|
||||
op.create_index(op.f('ix_tcgplayer_products_id'), 'tcgplayer_products', ['id'], unique=False)
|
||||
op.create_table('mtgjson_skus',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('mtgjson_uuid', sa.String(), nullable=True),
|
||||
sa.Column('tcgplayer_sku_id', sa.Integer(), nullable=True),
|
||||
sa.Column('tcgplayer_product_id', sa.Integer(), nullable=False),
|
||||
sa.Column('normalized_printing', sa.String(), nullable=False),
|
||||
sa.Column('condition', sa.String(), nullable=True),
|
||||
sa.Column('finish', sa.String(), nullable=True),
|
||||
sa.Column('language', sa.String(), nullable=True),
|
||||
sa.Column('printing', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['mtgjson_uuid'], ['mtgjson_cards.mtgjson_uuid'], ),
|
||||
sa.ForeignKeyConstraint(['tcgplayer_product_id', 'normalized_printing'], ['tcgplayer_products.tcgplayer_product_id', 'tcgplayer_products.normalized_sub_type_name'], name='fk_sku_to_product_composite'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('mtgjson_uuid', name='uq_sku_mtgjson_uuid')
|
||||
)
|
||||
op.create_index('idx_sku_product_printing', 'mtgjson_skus', ['tcgplayer_product_id', 'normalized_printing'], unique=False)
|
||||
op.create_index(op.f('ix_mtgjson_skus_id'), 'mtgjson_skus', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_mtgjson_skus_mtgjson_uuid'), 'mtgjson_skus', ['mtgjson_uuid'], unique=True)
|
||||
op.create_index(op.f('ix_mtgjson_skus_tcgplayer_sku_id'), 'mtgjson_skus', ['tcgplayer_sku_id'], unique=True)
|
||||
op.create_table('physical_items',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('item_type', sa.String(), nullable=True),
|
||||
sa.Column('tcgplayer_product_id', sa.Integer(), nullable=True),
|
||||
sa.Column('tcgplayer_sku_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.CheckConstraint('(tcgplayer_sku_id IS NOT NULL OR tcgplayer_product_id IS NOT NULL)', name='ck_physical_items_sku_or_product_not_null'),
|
||||
sa.ForeignKeyConstraint(['tcgplayer_sku_id'], ['mtgjson_skus.tcgplayer_sku_id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('tcgplayer_inventory',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('tcgplayer_sku_id', sa.Integer(), nullable=True),
|
||||
sa.Column('product_line', sa.String(), nullable=True),
|
||||
sa.Column('set_name', sa.String(), nullable=True),
|
||||
sa.Column('product_name', sa.String(), nullable=True),
|
||||
sa.Column('title', sa.String(), nullable=True),
|
||||
sa.Column('number', sa.String(), nullable=True),
|
||||
sa.Column('rarity', sa.String(), nullable=True),
|
||||
sa.Column('condition', sa.String(), nullable=True),
|
||||
sa.Column('tcg_market_price', sa.Float(), nullable=True),
|
||||
sa.Column('tcg_direct_low', sa.Float(), nullable=True),
|
||||
sa.Column('tcg_low_price_with_shipping', sa.Float(), nullable=True),
|
||||
sa.Column('tcg_low_price', sa.Float(), nullable=True),
|
||||
sa.Column('total_quantity', sa.Integer(), nullable=True),
|
||||
sa.Column('add_to_quantity', sa.Integer(), nullable=True),
|
||||
sa.Column('tcg_marketplace_price', sa.Float(), nullable=True),
|
||||
sa.Column('photo_url', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['tcgplayer_sku_id'], ['mtgjson_skus.tcgplayer_sku_id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tcgplayer_inventory_id'), 'tcgplayer_inventory', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_tcgplayer_inventory_tcgplayer_sku_id'), 'tcgplayer_inventory', ['tcgplayer_sku_id'], unique=True)
|
||||
op.create_table('boxes',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('expected_value', sa.Float(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['id'], ['physical_items.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('cards',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['id'], ['physical_items.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('cases',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('expected_value', sa.Float(), nullable=True),
|
||||
sa.Column('num_boxes', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['id'], ['physical_items.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('inventory_items',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('physical_item_id', sa.Integer(), nullable=True),
|
||||
sa.Column('cost_basis', sa.Float(), nullable=True),
|
||||
sa.Column('parent_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['parent_id'], ['inventory_items.id'], ),
|
||||
sa.ForeignKeyConstraint(['physical_item_id'], ['physical_items.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('physical_item_id')
|
||||
)
|
||||
op.create_index(op.f('ix_inventory_items_id'), 'inventory_items', ['id'], unique=False)
|
||||
op.create_table('open_events',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('source_item_id', sa.Integer(), nullable=True),
|
||||
sa.Column('open_date', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['source_item_id'], ['physical_items.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('source_item_id', name='uq_openevent_one_per_source')
|
||||
)
|
||||
op.create_index(op.f('ix_open_events_id'), 'open_events', ['id'], unique=False)
|
||||
op.create_table('marketplace_listings',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('inventory_item_id', sa.Integer(), nullable=False),
|
||||
sa.Column('marketplace_id', sa.Integer(), nullable=False),
|
||||
sa.Column('listing_date', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('delisting_date', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('listed_price', sa.Float(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['inventory_item_id'], ['inventory_items.id'], ),
|
||||
sa.ForeignKeyConstraint(['marketplace_id'], ['marketplaces.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_marketplace_listings_id'), 'marketplace_listings', ['id'], unique=False)
|
||||
op.create_table('open_event_resulting_items',
|
||||
sa.Column('event_id', sa.Integer(), nullable=False),
|
||||
sa.Column('item_id', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['event_id'], ['open_events.id'], ),
|
||||
sa.ForeignKeyConstraint(['item_id'], ['physical_items.id'], ),
|
||||
sa.PrimaryKeyConstraint('event_id', 'item_id')
|
||||
)
|
||||
op.create_table('transaction_items',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('transaction_id', sa.Integer(), nullable=True),
|
||||
sa.Column('inventory_item_id', sa.Integer(), nullable=True),
|
||||
sa.Column('unit_price', sa.Float(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['inventory_item_id'], ['inventory_items.id'], ),
|
||||
sa.ForeignKeyConstraint(['transaction_id'], ['transactions.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_transaction_items_id'), 'transaction_items', ['id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_transaction_items_id'), table_name='transaction_items')
|
||||
op.drop_table('transaction_items')
|
||||
op.drop_table('open_event_resulting_items')
|
||||
op.drop_index(op.f('ix_marketplace_listings_id'), table_name='marketplace_listings')
|
||||
op.drop_table('marketplace_listings')
|
||||
op.drop_index(op.f('ix_open_events_id'), table_name='open_events')
|
||||
op.drop_table('open_events')
|
||||
op.drop_index(op.f('ix_inventory_items_id'), table_name='inventory_items')
|
||||
op.drop_table('inventory_items')
|
||||
op.drop_table('cases')
|
||||
op.drop_table('cards')
|
||||
op.drop_table('boxes')
|
||||
op.drop_index(op.f('ix_tcgplayer_inventory_tcgplayer_sku_id'), table_name='tcgplayer_inventory')
|
||||
op.drop_index(op.f('ix_tcgplayer_inventory_id'), table_name='tcgplayer_inventory')
|
||||
op.drop_table('tcgplayer_inventory')
|
||||
op.drop_table('physical_items')
|
||||
op.drop_index(op.f('ix_mtgjson_skus_tcgplayer_sku_id'), table_name='mtgjson_skus')
|
||||
op.drop_index(op.f('ix_mtgjson_skus_mtgjson_uuid'), table_name='mtgjson_skus')
|
||||
op.drop_index(op.f('ix_mtgjson_skus_id'), table_name='mtgjson_skus')
|
||||
op.drop_index('idx_sku_product_printing', table_name='mtgjson_skus')
|
||||
op.drop_table('mtgjson_skus')
|
||||
op.drop_index(op.f('ix_tcgplayer_products_id'), table_name='tcgplayer_products')
|
||||
op.drop_table('tcgplayer_products')
|
||||
op.drop_index(op.f('ix_transactions_id'), table_name='transactions')
|
||||
op.drop_table('transactions')
|
||||
op.drop_index(op.f('ix_tcgplayer_groups_id'), table_name='tcgplayer_groups')
|
||||
op.drop_index(op.f('ix_tcgplayer_groups_group_id'), table_name='tcgplayer_groups')
|
||||
op.drop_table('tcgplayer_groups')
|
||||
op.drop_table('manabox_import_staging')
|
||||
op.drop_index(op.f('ix_vendors_name'), table_name='vendors')
|
||||
op.drop_index(op.f('ix_vendors_id'), table_name='vendors')
|
||||
op.drop_table('vendors')
|
||||
op.drop_index(op.f('ix_tcgplayer_price_history_sub_type_name'), table_name='tcgplayer_price_history')
|
||||
op.drop_index(op.f('ix_tcgplayer_price_history_product_id'), table_name='tcgplayer_price_history')
|
||||
op.drop_index(op.f('ix_tcgplayer_price_history_id'), table_name='tcgplayer_price_history')
|
||||
op.drop_index(op.f('ix_tcgplayer_price_history_date'), table_name='tcgplayer_price_history')
|
||||
op.drop_index('idx_price_history_product_subtype_date', table_name='tcgplayer_price_history')
|
||||
op.drop_table('tcgplayer_price_history')
|
||||
op.drop_index(op.f('ix_tcgplayer_orders_order_number'), table_name='tcgplayer_orders')
|
||||
op.drop_index(op.f('ix_tcgplayer_orders_id'), table_name='tcgplayer_orders')
|
||||
op.drop_table('tcgplayer_orders')
|
||||
op.drop_index(op.f('ix_tcgplayer_order_transactions_order_number'), table_name='tcgplayer_order_transactions')
|
||||
op.drop_index(op.f('ix_tcgplayer_order_transactions_id'), table_name='tcgplayer_order_transactions')
|
||||
op.drop_table('tcgplayer_order_transactions')
|
||||
op.drop_index(op.f('ix_tcgplayer_order_refunds_order_number'), table_name='tcgplayer_order_refunds')
|
||||
op.drop_index(op.f('ix_tcgplayer_order_refunds_id'), table_name='tcgplayer_order_refunds')
|
||||
op.drop_table('tcgplayer_order_refunds')
|
||||
op.drop_index(op.f('ix_tcgplayer_order_products_order_number'), table_name='tcgplayer_order_products')
|
||||
op.drop_index(op.f('ix_tcgplayer_order_products_id'), table_name='tcgplayer_order_products')
|
||||
op.drop_table('tcgplayer_order_products')
|
||||
op.drop_index(op.f('ix_tcgplayer_categories_id'), table_name='tcgplayer_categories')
|
||||
op.drop_index(op.f('ix_tcgplayer_categories_category_id'), table_name='tcgplayer_categories')
|
||||
op.drop_table('tcgplayer_categories')
|
||||
op.drop_index(op.f('ix_sealed_expected_values_id'), table_name='sealed_expected_values')
|
||||
op.drop_index('idx_sealed_expected_value_product_id_deleted_at', table_name='sealed_expected_values')
|
||||
op.drop_table('sealed_expected_values')
|
||||
op.drop_index(op.f('ix_mtgjson_cards_mtgjson_uuid'), table_name='mtgjson_cards')
|
||||
op.drop_index(op.f('ix_mtgjson_cards_id'), table_name='mtgjson_cards')
|
||||
op.drop_table('mtgjson_cards')
|
||||
op.drop_index(op.f('ix_most_recent_tcgplayer_price_sub_type_name'), table_name='most_recent_tcgplayer_price')
|
||||
op.drop_index(op.f('ix_most_recent_tcgplayer_price_product_id'), table_name='most_recent_tcgplayer_price')
|
||||
op.drop_index('idx_most_recent_price_product_subtype', table_name='most_recent_tcgplayer_price')
|
||||
op.drop_table('most_recent_tcgplayer_price')
|
||||
op.drop_index(op.f('ix_marketplaces_name'), table_name='marketplaces')
|
||||
op.drop_index(op.f('ix_marketplaces_id'), table_name='marketplaces')
|
||||
op.drop_table('marketplaces')
|
||||
op.drop_index(op.f('ix_files_id'), table_name='files')
|
||||
op.drop_table('files')
|
||||
op.drop_index(op.f('ix_customers_name'), table_name='customers')
|
||||
op.drop_index(op.f('ix_customers_id'), table_name='customers')
|
||||
op.drop_table('customers')
|
||||
op.drop_index(op.f('ix_critical_error_logs_id'), table_name='critical_error_logs')
|
||||
op.drop_table('critical_error_logs')
|
||||
# ### end Alembic commands ###
|
@ -0,0 +1,34 @@
|
||||
"""there is literally no point to ever using foreign keys
|
||||
|
||||
Revision ID: d13600612a8f
|
||||
Revises: 62eee00bae8e
|
||||
Create Date: 2025-04-28 11:37:11.023788
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'd13600612a8f'
|
||||
down_revision: Union[str, None] = '62eee00bae8e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint('tcgplayer_inventory_tcgplayer_sku_id_fkey', 'tcgplayer_inventory', type_='foreignkey')
|
||||
op.drop_constraint('unmanaged_tcgplayer_inventory_tcgplayer_sku_id_fkey', 'unmanaged_tcgplayer_inventory', type_='foreignkey')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_foreign_key('unmanaged_tcgplayer_inventory_tcgplayer_sku_id_fkey', 'unmanaged_tcgplayer_inventory', 'mtgjson_skus', ['tcgplayer_sku_id'], ['tcgplayer_sku_id'])
|
||||
op.create_foreign_key('tcgplayer_inventory_tcgplayer_sku_id_fkey', 'tcgplayer_inventory', 'mtgjson_skus', ['tcgplayer_sku_id'], ['tcgplayer_sku_id'])
|
||||
# ### end Alembic commands ###
|
33
app.log
33
app.log
@ -1,33 +0,0 @@
|
||||
2025-04-17 13:27:20,268 - INFO - app.main - Application starting up...
|
||||
2025-04-17 13:27:20,319 - INFO - app.main - Database initialized successfully
|
||||
2025-04-17 13:27:20,366 - INFO - app.services.service_manager - Service OrderManagementService registered
|
||||
2025-04-17 13:27:20,366 - INFO - app.services.service_manager - Service TCGPlayerInventoryService registered
|
||||
2025-04-17 13:27:20,369 - INFO - app.services.service_manager - Service LabelPrinterService registered
|
||||
2025-04-17 13:27:20,372 - INFO - app.services.service_manager - Service RegularPrinterService registered
|
||||
2025-04-17 13:27:20,387 - INFO - app.services.service_manager - Service AddressLabelService registered
|
||||
2025-04-17 13:27:20,592 - INFO - app.services.service_manager - Service PullSheetService registered
|
||||
2025-04-17 13:27:20,592 - INFO - app.services.service_manager - Service SetLabelService registered
|
||||
2025-04-17 13:27:20,634 - INFO - app.services.service_manager - Service DataInitializationService registered
|
||||
2025-04-17 13:27:20,652 - INFO - app.services.service_manager - Service SchedulerService registered
|
||||
2025-04-17 13:27:20,652 - INFO - app.services.service_manager - Service FileService registered
|
||||
2025-04-17 13:27:20,653 - INFO - app.services.service_manager - All services initialized successfully
|
||||
2025-04-17 13:27:20,653 - INFO - apscheduler.scheduler - Adding job tentatively -- it will be properly scheduled when the scheduler starts
|
||||
2025-04-17 13:27:20,653 - INFO - app.services.scheduler.base_scheduler - Scheduled task update_open_orders_hourly to run every 3600 seconds
|
||||
2025-04-17 13:27:20,653 - INFO - apscheduler.scheduler - Adding job tentatively -- it will be properly scheduled when the scheduler starts
|
||||
2025-04-17 13:27:20,653 - INFO - app.services.scheduler.base_scheduler - Scheduled task update_all_orders_daily to run every 86400 seconds
|
||||
2025-04-17 13:27:20,653 - INFO - apscheduler.scheduler - Added job "SchedulerService.update_open_orders_hourly" to job store "default"
|
||||
2025-04-17 13:27:20,653 - INFO - apscheduler.scheduler - Added job "SchedulerService.update_all_orders_daily" to job store "default"
|
||||
2025-04-17 13:27:20,653 - INFO - apscheduler.scheduler - Scheduler started
|
||||
2025-04-17 13:27:20,653 - INFO - app.services.scheduler.base_scheduler - Scheduler started
|
||||
2025-04-17 13:27:20,653 - INFO - app.services.scheduler.scheduler_service - All scheduled tasks started
|
||||
2025-04-17 13:27:20,653 - INFO - app.main - Scheduler started successfully
|
||||
2025-04-17 13:27:24,285 - INFO - app.services.regular_printer_service - Print job 85 submitted to printer MFCL2750DW-3
|
||||
2025-04-17 13:28:05,282 - INFO - app.services.external_api.base_external_service - Making request to https://order-management-api.tcgplayer.com/orders/packing-slips/export?api-version=2.0
|
||||
2025-04-17 13:28:05,417 - INFO - app.services.label_printer_service - Converting PDF app/data/cache/tcgplayer/packing_slips/pdf/packing_slip_2025-04-17_13-28-05.pdf to images
|
||||
2025-04-17 13:28:05,489 - INFO - app.services.label_printer_service - Successfully converted PDF to 2 images
|
||||
2025-04-17 13:28:05,489 - INFO - app.services.label_printer_service - Processing page 1 with dimensions (1700, 2200)
|
||||
2025-04-17 13:28:09,731 - INFO - app.services.label_printer_service - Processing page 2 with dimensions (1700, 2200)
|
||||
2025-04-17 13:28:15,097 - INFO - app.services.label_printer_service - Converting PDF app/data/cache/tcgplayer/packing_slips/pdf/packing_slip_2025-04-17_13-28-05.pdf to images
|
||||
2025-04-17 13:28:15,167 - INFO - app.services.label_printer_service - Successfully converted PDF to 2 images
|
||||
2025-04-17 13:28:15,167 - INFO - app.services.label_printer_service - Processing page 1 with dimensions (1700, 2200)
|
||||
2025-04-17 13:28:19,411 - INFO - app.services.label_printer_service - Processing page 2 with dimensions (1700, 2200)
|
87
app/contexts/inventory_item.py
Normal file
87
app/contexts/inventory_item.py
Normal file
@ -0,0 +1,87 @@
|
||||
from app.models.inventory_management import InventoryItem
|
||||
from app.contexts.inventory_product import InventoryProductContext
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from app.models.tcgplayer_products import TCGPlayerProduct
|
||||
|
||||
class InventoryItemContext:
|
||||
def __init__(self, item: InventoryItem, db: Session):
|
||||
self.item = item
|
||||
self.physical_item = item.physical_item
|
||||
self.marketplace_listing = item.marketplace_listing
|
||||
self.parent = item.parent
|
||||
self.children = item.children
|
||||
self.product = item.products
|
||||
self.db = db
|
||||
|
||||
@property
|
||||
def cost_basis(self) -> float:
|
||||
return self.item.cost_basis
|
||||
|
||||
@property
|
||||
def product_id(self) -> int:
|
||||
return self.physical_item.product_id
|
||||
|
||||
@property
|
||||
def product_name(self) -> str:
|
||||
return self.product.name if self.product else None
|
||||
|
||||
@property
|
||||
def item_type(self) -> str:
|
||||
return self.physical_item.item_type
|
||||
|
||||
@property
|
||||
def market_price(self) -> float:
|
||||
if not self.product or not self.product.most_recent_tcgplayer_price:
|
||||
return 0.0
|
||||
return self.product.most_recent_tcgplayer_price.market_price
|
||||
|
||||
@property
|
||||
def tcg_low_price(self) -> float:
|
||||
if not self.product or not self.product.most_recent_tcgplayer_price:
|
||||
return 0.0
|
||||
return self.product.most_recent_tcgplayer_price.low_price
|
||||
|
||||
@property
|
||||
def listed_price(self) -> float:
|
||||
if not self.marketplace_listing:
|
||||
return 0.0
|
||||
return self.marketplace_listing[0].listed_price if self.marketplace_listing else 0.0
|
||||
|
||||
def top_level_parent(self) -> "InventoryItemContext":
|
||||
if self.parent:
|
||||
return InventoryItemContext(self.parent, self.db)
|
||||
return self
|
||||
|
||||
def box_expected_value(self) -> float:
|
||||
top_level_parent = self.top_level_parent()
|
||||
if 'case' in top_level_parent.item_type:
|
||||
return top_level_parent.physical_item.open_event.sealed_case.expected_value
|
||||
elif 'box' in top_level_parent.item_type:
|
||||
return top_level_parent.physical_item.open_event.sealed_box.expected_value
|
||||
else:
|
||||
raise ValueError("Unknown top level parent item type")
|
||||
|
||||
def box_acquisition_cost(self) -> float:
|
||||
if self.physical_item.transaction_items:
|
||||
return self.physical_item.transaction_items[0].unit_price
|
||||
elif self.parent:
|
||||
return InventoryItemContext(self.parent, self.db).box_acquisition_cost()
|
||||
else:
|
||||
raise ValueError("Cannot find transaction unit price for this item")
|
||||
|
||||
def age_on_marketplace(self) -> int:
|
||||
if not self.marketplace_listing:
|
||||
return 0
|
||||
return (datetime.now() - self.marketplace_listing[0].listing_date).days
|
||||
|
||||
|
||||
class InventoryItemContextFactory:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_context(self, item: InventoryItem) -> InventoryItemContext:
|
||||
return InventoryItemContext(item, self.db)
|
||||
|
||||
def get_context_for_product(self, product: TCGPlayerProduct) -> InventoryProductContext:
|
||||
return InventoryProductContext(product, self.db)
|
27
app/contexts/inventory_product.py
Normal file
27
app/contexts/inventory_product.py
Normal file
@ -0,0 +1,27 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.tcgplayer_products import TCGPlayerProduct
|
||||
class InventoryProductContext:
|
||||
def __init__(self, product: TCGPlayerProduct, db: Session):
|
||||
self.product = product
|
||||
self.prices = product.most_recent_tcgplayer_price
|
||||
self.db = db
|
||||
|
||||
@property
|
||||
def market_price(self) -> float:
|
||||
return self.product.most_recent_tcgplayer_price.market_price
|
||||
|
||||
@property
|
||||
def low_price(self) -> float:
|
||||
return self.product.most_recent_tcgplayer_price.low_price
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.product.name
|
||||
|
||||
@property
|
||||
def image_url(self) -> str:
|
||||
return self.product.image_url
|
||||
|
||||
@property
|
||||
def id(self) -> int:
|
||||
return self.product.id
|
@ -90,6 +90,12 @@ tr:hover {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.color-identity {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
width: 200px;
|
||||
}
|
||||
@ -119,6 +125,7 @@ tbody tr:hover {
|
||||
<th class="set">Set</th>
|
||||
<th class="rarity">Rarity</th>
|
||||
<th class="card-number">Card #</th>
|
||||
<th class="color-identity">Colors</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -129,6 +136,7 @@ tbody tr:hover {
|
||||
<td class="set">{{ item.set }}</td>
|
||||
<td class="rarity">{{ item.rarity }}</td>
|
||||
<td class="card-number">{{ item.card_number }}</td>
|
||||
<td class="color-identity">{{ item.color_identity }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
94
app/data/test_data/dragon.csv
Normal file
94
app/data/test_data/dragon.csv
Normal file
@ -0,0 +1,94 @@
|
||||
Name,Set code,Set name,Collector number,Foil,Rarity,Quantity,ManaBox ID,Scryfall ID,Purchase price,Misprint,Altered,Condition,Language,Purchase price currency
|
||||
Undergrowth Leopard,TDM,Tarkir: Dragonstorm,165,foil,common,1,104307,67ab8f9a-b17c-452f-b4ef-a3f91909e3de,0.08,false,false,near_mint,en,USD
|
||||
Gurmag Nightwatch,TDM,Tarkir: Dragonstorm,190,foil,common,1,104369,de731430-6bbf-4782-953e-b69c46353959,0.03,false,false,near_mint,en,USD
|
||||
Mystic Monastery,TDM,Tarkir: Dragonstorm,262,foil,uncommon,1,104945,c7b8a01c-c400-47c7-8270-78902efe850e,0.21,false,false,near_mint,en,USD
|
||||
Stormshriek Feral // Flush Out,TDM,Tarkir: Dragonstorm,124,foil,common,1,104447,0ec92c44-7cf0-48a5-a3ca-bc633496d887,0.1,false,false,near_mint,en,USD
|
||||
Reigning Victor,TDM,Tarkir: Dragonstorm,216,foil,common,1,104334,a394112a-032b-4047-887a-6522cf7b83d5,0.02,false,false,near_mint,en,USD
|
||||
Dragonbroods' Relic,TDM,Tarkir: Dragonstorm,140,foil,uncommon,1,104569,3d634087-77ba-4543-aa7a-8a3774d69cd7,0.13,false,false,near_mint,en,USD
|
||||
Sagu Wildling // Roost Seek,TDM,Tarkir: Dragonstorm,306,foil,common,1,104903,b72ee8f9-5e79-4f77-ae7e-e4c274f78187,0.13,false,false,near_mint,en,USD
|
||||
Sibsig Appraiser,TDM,Tarkir: Dragonstorm,56,foil,common,1,105135,670c5b96-bac6-449b-a2bd-cb43750d3911,0.05,false,false,near_mint,en,USD
|
||||
Sage of the Fang,TDM,Tarkir: Dragonstorm,155,foil,uncommon,1,105123,1ebf4a9d-d90c-4017-9f00-fca89899f301,0.09,false,false,near_mint,en,USD
|
||||
Snowmelt Stag,TDM,Tarkir: Dragonstorm,57,foil,common,1,104869,a6b3b131-704a-4586-84f8-db465cd4a277,0.04,false,false,near_mint,en,USD
|
||||
Tranquil Cove,TDM,Tarkir: Dragonstorm,270,foil,common,1,104249,1c4efa6c-4f29-41cd-a728-bf0e479ace05,0.07,false,false,near_mint,en,USD
|
||||
Rally the Monastery,TDM,Tarkir: Dragonstorm,19,foil,uncommon,1,104136,b56e0037-8143-4c13-83e1-0c3f44e685ea,0.22,false,false,near_mint,en,USD
|
||||
Dragon's Prey,TDM,Tarkir: Dragonstorm,79,foil,common,1,104754,7a6004ff-4180-4332-8b51-960f8c7521d9,0.03,false,false,near_mint,en,USD
|
||||
Ambling Stormshell,TDM,Tarkir: Dragonstorm,37,foil,rare,1,104942,c74d4a57-0f66-4965-9ed7-f88a08aa1d15,0.45,false,false,near_mint,en,USD
|
||||
Mountain,TDM,Tarkir: Dragonstorm,275,foil,common,1,104397,fe0865ba-47c0-40bc-b0c6-e1ea5ae08a98,1.83,false,false,near_mint,en,USD
|
||||
Mardu Devotee,TDM,Tarkir: Dragonstorm,16,foil,common,1,104366,da45e9b0-a4f6-413b-9e62-666c511eb5b0,0.09,false,false,near_mint,en,USD
|
||||
Swiftwater Cliffs,TDM,Tarkir: Dragonstorm,268,foil,common,1,104361,ca53fb19-b8ca-485b-af1a-5117ae54bfe3,0.11,false,false,near_mint,en,USD
|
||||
Adorned Crocodile,TDM,Tarkir: Dragonstorm,69,foil,common,1,105159,bb13a34b-6ac8-47cb-9e91-47106a585fc1,0.05,false,false,near_mint,en,USD
|
||||
Dusyut Earthcarver,TDM,Tarkir: Dragonstorm,141,foil,common,1,104352,b98ecc96-f557-479a-8685-2b5487d5b407,0.02,false,false,near_mint,en,USD
|
||||
Knockout Maneuver,TDM,Tarkir: Dragonstorm,147,foil,uncommon,1,105149,9d218831-2a41-46a3-8e9d-93462cae5cab,0.07,false,false,near_mint,en,USD
|
||||
Roiling Dragonstorm,TDM,Tarkir: Dragonstorm,55,foil,uncommon,1,104280,455f4c96-684b-4b14-bd21-6799da2e1fa7,0.22,false,false,near_mint,en,USD
|
||||
Dragonclaw Strike,TDM,Tarkir: Dragonstorm,180,foil,uncommon,1,105161,bc7692ef-7091-4365-85a8-1edbd374f279,0.12,false,false,near_mint,en,USD
|
||||
Seize Opportunity,TDM,Tarkir: Dragonstorm,119,foil,common,1,104391,f7818d28-b9a5-4341-9adc-666070b8878d,0.03,false,false,near_mint,en,USD
|
||||
Shock Brigade,TDM,Tarkir: Dragonstorm,120,foil,common,1,104700,66940466-8e9d-4a85-bfb0-e92189b7a121,0.11,false,false,near_mint,en,USD
|
||||
Hardened Tactician,TDM,Tarkir: Dragonstorm,191,foil,uncommon,1,104780,86b225cb-5c45-4da1-a64e-b04091e483e8,0.43,false,false,near_mint,en,USD
|
||||
Stormplain Detainment,TDM,Tarkir: Dragonstorm,28,foil,common,1,104135,39f3aab5-7b54-4b55-8114-c6f9f79c255d,0.04,false,false,near_mint,en,USD
|
||||
Formation Breaker,TDM,Tarkir: Dragonstorm,143,foil,uncommon,1,105136,67ab8e8f-3ef6-4339-8c66-68c5aca4867a,0.08,false,false,near_mint,en,USD
|
||||
Duty Beyond Death,TDM,Tarkir: Dragonstorm,10,foil,uncommon,1,104265,2e92640d-768b-4357-905f-bea017d351cc,1.11,false,false,near_mint,en,USD
|
||||
Piercing Exhale,TDM,Tarkir: Dragonstorm,151,foil,common,1,104891,b2a0deb9-5bc3-42d5-9e1e-5f463d176aef,0.04,false,false,near_mint,en,USD
|
||||
Trade Route Envoy,TDM,Tarkir: Dragonstorm,163,foil,common,1,105174,f0c89d95-d697-4cfa-9dfa-52d7adb96176,0.05,false,false,near_mint,en,USD
|
||||
Thornwood Falls,TDM,Tarkir: Dragonstorm,269,foil,common,1,104376,ebb502c2-5fd0-46a9-b77d-010f4a942056,0.07,false,false,near_mint,en,USD
|
||||
Kin-Tree Nurturer,TDM,Tarkir: Dragonstorm,83,foil,common,1,105124,2177ef64-28bf-4acf-b1f1-c1408f03c411,0.03,false,false,near_mint,en,USD
|
||||
Rebellious Strike,TDM,Tarkir: Dragonstorm,20,foil,common,1,104949,c9bafe19-3bd6-4da0-b3e5-e0b89262504c,0.06,false,false,near_mint,en,USD
|
||||
Scoured Barrens,TDM,Tarkir: Dragonstorm,267,foil,common,1,104346,b4b47b80-69ed-44b0-afa0-ca90206dc16d,0.06,false,false,near_mint,en,USD
|
||||
Avenger of the Fallen,TDM,Tarkir: Dragonstorm,73,foil,rare,1,104984,d5397366-151f-46b0-b9b2-fa4d5bd892d8,0.68,false,false,near_mint,en,USD
|
||||
Dismal Backwater,TDM,Tarkir: Dragonstorm,254,foil,common,1,104238,082b52c9-c46e-44d3-b723-546ba528e07b,0.07,false,false,near_mint,en,USD
|
||||
Rediscover the Way,TDM,Tarkir: Dragonstorm,215,normal,rare,1,104313,79d6decf-afd5-4e96-b87e-fd7ab7e3c068,0.19,false,false,near_mint,en,USD
|
||||
Auroral Procession,TDM,Tarkir: Dragonstorm,169,normal,uncommon,1,104701,672f94ad-65d6-4c7d-925d-165ef264626f,0.22,false,false,near_mint,en,USD
|
||||
Runescale Stormbrood // Chilling Screech,TDM,Tarkir: Dragonstorm,316,normal,uncommon,1,104733,72e8f916-5a01-4918-bcb5-7fd69fe32785,0.31,false,false,near_mint,en,USD
|
||||
Stadium Headliner,TDM,Tarkir: Dragonstorm,122,normal,rare,1,104552,37d4ab2a-a06a-4768-b5e1-e1def957d7f4,0.44,false,false,near_mint,en,USD
|
||||
Nature's Rhythm,TDM,Tarkir: Dragonstorm,150,normal,rare,1,104460,1397d904-c51d-451e-8505-7f3118acc1f6,3.08,false,false,near_mint,en,USD
|
||||
Karakyk Guardian,TDM,Tarkir: Dragonstorm,198,normal,uncommon,1,104859,a4c77b08-c3f6-4458-8636-f226f9843b6d,0.08,false,false,near_mint,en,USD
|
||||
"Anafenza, Unyielding Lineage",TDM,Tarkir: Dragonstorm,2,normal,rare,1,104258,29957f49-9a6b-42f6-b2fb-b48f653ab725,0.22,false,false,near_mint,en,USD
|
||||
"Narset, Jeskai Waymaster",TDM,Tarkir: Dragonstorm,209,normal,rare,1,103995,6b77cbc1-dbc8-44d9-aa29-15cbb19afecd,0.22,false,false,near_mint,en,USD
|
||||
Stillness in Motion,TDM,Tarkir: Dragonstorm,59,normal,rare,1,104864,a6289251-17e4-4987-96b9-2fb1a8f90e2a,0.17,false,false,near_mint,en,USD
|
||||
Thunder of Unity,TDM,Tarkir: Dragonstorm,231,normal,rare,1,104671,5c953b36-f5e4-4258-91cb-f07e799321f7,0.14,false,false,near_mint,en,USD
|
||||
The Sibsig Ceremony,TDM,Tarkir: Dragonstorm,340,normal,rare,1,104719,6daa156c-478f-47dd-9284-b95e82ccfd68,0.67,false,false,near_mint,en,USD
|
||||
Tersa Lightshatter,TDM,Tarkir: Dragonstorm,127,normal,rare,1,104825,99e96b34-b1c4-4647-a38e-2cf1aedaaace,2.31,false,false,near_mint,en,USD
|
||||
Forest,TDM,Tarkir: Dragonstorm,286,normal,common,1,104324,8e3e83d2-96ba-4d5c-a1ed-6c08a90b339c,0.07,false,false,near_mint,en,USD
|
||||
Windcrag Siege,TDM,Tarkir: Dragonstorm,235,normal,rare,1,104534,31a8329b-23a1-4c49-a579-a5da8d01435a,1.68,false,false,near_mint,en,USD
|
||||
Mountain,TDM,Tarkir: Dragonstorm,284,normal,common,1,104274,3df7c206-97b6-49d7-ba01-7a35fd8c61d9,0.05,false,false,near_mint,en,USD
|
||||
Inevitable Defeat,TDM,Tarkir: Dragonstorm,194,normal,rare,1,103997,9d677980-b608-407e-9f17-790a81263f15,0.28,false,false,near_mint,en,USD
|
||||
Dalkovan Encampment,TDM,Tarkir: Dragonstorm,394,normal,rare,1,104670,5af006f6-135e-4ea0-8ce4-7824934e87da,0.72,false,false,near_mint,en,USD
|
||||
Host of the Hereafter,TDM,Tarkir: Dragonstorm,193,normal,uncommon,1,104448,0f182957-8133-45a7-80a3-1944bead4d43,0.14,false,false,near_mint,en,USD
|
||||
"Sarkhan, Dragon Ascendant",TDM,Tarkir: Dragonstorm,118,normal,rare,1,104003,c2200646-7b7c-489d-bbae-16b03e1d7fb2,0.32,false,false,near_mint,en,USD
|
||||
Stormscale Scion,TDM,Tarkir: Dragonstorm,123,normal,mythic,1,103987,0ac43386-bd32-425c-8776-cec00b064cbc,6.78,false,false,near_mint,en,USD
|
||||
Dragon Sniper,TDM,Tarkir: Dragonstorm,139,normal,uncommon,1,105120,074b1e00-45bb-4436-8f5e-058512b2d08a,0.25,false,false,near_mint,en,USD
|
||||
Island,TDM,Tarkir: Dragonstorm,273,normal,common,1,104276,4208e66c-8c98-4c48-ab07-8523c0b26ca4,1.02,false,false,near_mint,en,USD
|
||||
Yathan Roadwatcher,TDM,Tarkir: Dragonstorm,236,normal,rare,1,104800,8e77339b-dd82-481c-9ee2-4156ca69ad35,0.14,false,false,near_mint,en,USD
|
||||
Nomad Outpost,TDM,Tarkir: Dragonstorm,263,normal,uncommon,1,104868,a68fbeaa-941f-4d53-becd-f93ed22b9a54,0.12,false,false,near_mint,en,USD
|
||||
Sage of the Skies,TDM,Tarkir: Dragonstorm,22,normal,rare,1,104710,6ade6918-6d1d-448d-ab56-93996051e9a9,0.21,false,false,near_mint,en,USD
|
||||
Kheru Goldkeeper,TDM,Tarkir: Dragonstorm,199,normal,uncommon,1,104798,8d11183a-57f5-4ddb-8a6e-15fff704b114,0.18,false,false,near_mint,en,USD
|
||||
All-Out Assault,TDM,Tarkir: Dragonstorm,167,normal,mythic,1,104348,b74876d8-f6a6-4b47-b960-b01a331bab01,4.11,false,false,near_mint,en,USD
|
||||
Winternight Stories,TDM,Tarkir: Dragonstorm,67,normal,rare,1,104693,64d9367c-f50c-4568-aa63-6760c44ecaeb,0.44,false,false,near_mint,en,USD
|
||||
New Way Forward,TDM,Tarkir: Dragonstorm,211,normal,rare,1,104996,d9d48f9e-79f0-478c-9db0-ff7ac4a8f401,0.17,false,false,near_mint,en,USD
|
||||
Strategic Betrayal,TDM,Tarkir: Dragonstorm,94,normal,uncommon,1,105145,95617742-548d-464a-bb89-a858ffa9018f,0.18,false,false,near_mint,en,USD
|
||||
Opulent Palace,TDM,Tarkir: Dragonstorm,264,normal,uncommon,1,104491,21cb3b3b-0738-4c2e-a3fc-927fd6b9d3fb,0.14,false,false,near_mint,en,USD
|
||||
United Battlefront,TDM,Tarkir: Dragonstorm,32,normal,rare,1,104370,dff398be-4ba4-4976-9acc-be99d2e07a61,0.51,false,false,near_mint,en,USD
|
||||
Temur Battlecrier,TDM,Tarkir: Dragonstorm,228,normal,rare,1,104309,72184791-0767-4108-920c-763e92dae2d4,0.68,false,false,near_mint,en,USD
|
||||
"Kotis, the Fangkeeper",TDM,Tarkir: Dragonstorm,362,normal,rare,1,104388,f70098f2-e5a8-4056-b5b3-1229fc290c51,0.48,false,false,near_mint,en,USD
|
||||
Forest,TDM,Tarkir: Dragonstorm,285,normal,common,1,104317,8100bceb-ffba-487a-bb45-4fe2a156a8dc,0.06,false,false,near_mint,en,USD
|
||||
Dragonfire Blade,TDM,Tarkir: Dragonstorm,240,normal,rare,1,104427,031afea3-fbfb-4663-a8cc-9b7eb7b16020,0.64,false,false,near_mint,en,USD
|
||||
Great Arashin City,TDM,Tarkir: Dragonstorm,257,normal,rare,1,105033,ecba23b6-9f3a-431e-bc22-f1fb04d27b68,0.33,false,false,near_mint,en,USD
|
||||
Smile at Death,TDM,Tarkir: Dragonstorm,24,normal,mythic,1,104000,ae2da18f-0d7d-446c-b463-8bf170ed95da,3.51,false,false,near_mint,en,USD
|
||||
Maelstrom of the Spirit Dragon,TDM,Tarkir: Dragonstorm,260,normal,rare,1,104359,c4e90bfb-d9a5-48a9-9ff9-b0f50a813eee,1.31,false,false,near_mint,en,USD
|
||||
Eshki Dragonclaw,TDM,Tarkir: Dragonstorm,182,normal,rare,1,104445,0d369c44-78ee-4f3c-bf2b-cddba7fe26d4,0.19,false,false,near_mint,en,USD
|
||||
Skirmish Rhino,TDM,Tarkir: Dragonstorm,224,normal,uncommon,1,103992,4a2e9ba1-c254-41e3-9845-4e81f9fec38d,0.15,false,false,near_mint,en,USD
|
||||
"Teval, Arbiter of Virtue",TDM,Tarkir: Dragonstorm,373,normal,mythic,1,104332,a19c38bc-946c-438a-ac8b-f59ff0b4c613,7.06,false,false,near_mint,en,USD
|
||||
"Ureni, the Song Unending",TDM,Tarkir: Dragonstorm,233,normal,mythic,1,104253,227802c0-4ff6-43a8-a850-ed0f546dc5ac,3.79,false,false,near_mint,en,USD
|
||||
Hardened Tactician,TDM,Tarkir: Dragonstorm,191,normal,uncommon,1,104780,86b225cb-5c45-4da1-a64e-b04091e483e8,1.0,false,false,near_mint,en,USD
|
||||
Sandsteppe Citadel,TDM,Tarkir: Dragonstorm,266,normal,uncommon,1,104603,47f47e7f-39ba-4807-8e32-7262a61dfbba,0.13,false,false,near_mint,en,USD
|
||||
"Kotis, the Fangkeeper",TDM,Tarkir: Dragonstorm,202,normal,rare,1,104364,d3736f17-f80b-4b2c-b919-2c963bc14682,0.28,false,false,near_mint,en,USD
|
||||
Magmatic Hellkite,TDM,Tarkir: Dragonstorm,111,normal,rare,1,104895,b3b3aec8-d931-4c7f-86b5-1e7dfb717b59,0.56,false,false,near_mint,en,USD
|
||||
Dalkovan Encampment,TDM,Tarkir: Dragonstorm,253,normal,rare,1,104822,98ad5f0c-8775-4e89-8e92-84a6ade93e35,0.38,false,false,near_mint,en,USD
|
||||
Ambling Stormshell,TDM,Tarkir: Dragonstorm,37,normal,rare,1,104942,c74d4a57-0f66-4965-9ed7-f88a08aa1d15,0.18,false,false,near_mint,en,USD
|
||||
Hollowmurk Siege,TDM,Tarkir: Dragonstorm,192,normal,rare,1,104668,5ac0e136-8877-4bfc-a831-2bf7b7b5ad1e,0.53,false,false,near_mint,en,USD
|
||||
Avenger of the Fallen,TDM,Tarkir: Dragonstorm,73,normal,rare,1,104984,d5397366-151f-46b0-b9b2-fa4d5bd892d8,0.34,false,false,near_mint,en,USD
|
||||
Mystic Monastery,TDM,Tarkir: Dragonstorm,262,normal,uncommon,1,104945,c7b8a01c-c400-47c7-8270-78902efe850e,0.11,false,false,near_mint,en,USD
|
||||
Songcrafter Mage,TDM,Tarkir: Dragonstorm,225,normal,rare,1,104813,9523bc07-49e5-409c-ae6b-b28e305eef36,0.35,false,false,near_mint,en,USD
|
||||
Misty Rainforest,SPG,Special Guests,111,normal,mythic,1,104321,894105c4-d3ce-4d38-855b-24aa47b112c1,32.31,false,false,near_mint,en,USD
|
||||
Tempest Hawk,TDM,Tarkir: Dragonstorm,31,normal,common,3,104587,422f9453-ab12-4e3c-8c51-be87391395a1,0.92,false,false,near_mint,en,USD
|
||||
Duty Beyond Death,TDM,Tarkir: Dragonstorm,10,normal,uncommon,2,104265,2e92640d-768b-4357-905f-bea017d351cc,0.33,false,false,near_mint,en,USD
|
||||
Heritage Reclamation,TDM,Tarkir: Dragonstorm,145,normal,common,2,104636,4f8fee37-a050-4329-8b10-46d150e7a95e,0.2,false,false,near_mint,en,USD
|
|
504
app/data/test_data/manabox_test_file.csv
Normal file
504
app/data/test_data/manabox_test_file.csv
Normal file
@ -0,0 +1,504 @@
|
||||
Name,Set code,Set name,Collector number,Foil,Rarity,Quantity,ManaBox ID,Scryfall ID,Purchase price,Misprint,Altered,Condition,Language,Purchase price currency
|
||||
"Tinybones, Bauble Burglar",FDN,Foundations,72,normal,rare,1,101414,ff3d85bc-ef2d-4251-baf4-a14bd0cee61e,0.66,false,false,near_mint,en,USD
|
||||
Scrawling Crawler,FDN,Foundations,132,normal,rare,1,100912,a1176dcf-40ee-4342-aa74-791b8352e99a,4.81,false,false,near_mint,en,USD
|
||||
"Giada, Font of Hope",FDN,Foundations,141,normal,rare,1,100804,8ae6fc26-cfad-4da8-98d9-49c27c24d293,1.33,false,false,near_mint,en,USD
|
||||
Blasphemous Edict,FDN,Foundations,57,normal,rare,1,100168,11040ecd-3153-4029-b42b-1441bc51ec34,6.9,false,false,near_mint,en,USD
|
||||
"Drakuseth, Maw of Flames",FDN,Foundations,193,normal,rare,1,100092,029b1edb-e1de-4f1c-81df-8d17f4920318,0.33,false,false,near_mint,en,USD
|
||||
"Koma, World-Eater",FDN,Foundations,347,normal,rare,1,100792,8889e1ca-eec1-408b-b11e-98cc0a357a97,4.69,false,false,near_mint,en,USD
|
||||
"Ghalta, Primal Hunger",FDN,Foundations,222,normal,rare,1,100635,6a9c39e4-a8cf-42dd-8d0e-45634b335546,0.54,false,false,near_mint,en,USD
|
||||
Sire of Seven Deaths,FDN,Foundations,1,normal,mythic,1,100812,8d8432a7-1c8a-4cfb-947c-ecf9791063eb,18.63,false,false,near_mint,en,USD
|
||||
Hero's Downfall,FDN,Foundations,319,normal,uncommon,1,101639,10cedc6d-075a-4f9b-a858-e2c29809ee33,0.39,false,false,near_mint,en,USD
|
||||
"Etali, Primal Storm",FDN,Foundations,194,normal,rare,1,101037,b6af9894-95b5-4c8e-902f-a9ba70f02e4a,0.32,false,false,near_mint,en,USD
|
||||
High Fae Trickster,FDN,Foundations,307,normal,rare,1,100918,a21180a4-208f-4c13-a704-58403ddaf12f,3.39,false,false,near_mint,en,USD
|
||||
Mocking Sprite,FDN,Foundations,159,foil,common,1,101624,f6792f63-b651-497d-8aa5-cddf4cedeca8,0.09,false,false,near_mint,en,USD
|
||||
Bake into a Pie,FDN,Foundations,169,foil,common,1,101494,2ab0e660-86a3-4b92-82fa-77dcb5db947d,0.06,false,false,near_mint,en,USD
|
||||
Boltwave,FDN,Foundations,79,foil,uncommon,1,100810,8d1ec351-5e70-4eb2-b590-6bff94ef8178,4.27,false,false,near_mint,en,USD
|
||||
Jungle Hollow,FDN,Foundations,263,foil,common,1,101224,dc758e14-d370-45e4-bbc5-938fb4d21127,0.08,false,false,near_mint,en,USD
|
||||
Ambush Wolf,FDN,Foundations,98,foil,common,1,101492,2903832c-318e-42ab-bf58-c682ec2f7afd,0.03,false,false,near_mint,en,USD
|
||||
An Offer You Can't Refuse,FDN,Foundations,160,foil,uncommon,1,100948,a829747f-cf9b-4d81-ba66-9f0630ed4565,1.51,false,false,near_mint,en,USD
|
||||
Sower of Chaos,FDN,Foundations,95,foil,common,1,101556,7ff50606-491c-4946-8d03-719b01cfad77,0.02,false,false,near_mint,en,USD
|
||||
Guarded Heir,FDN,Foundations,14,foil,uncommon,1,100505,525ba5c7-3ce5-4e52-b8b5-96c9040a6738,0.06,false,false,near_mint,en,USD
|
||||
Wind-Scarred Crag,FDN,Foundations,271,foil,common,1,100684,759e99df-11a8-4aee-b6bc-344e84e10d94,0.08,false,false,near_mint,en,USD
|
||||
Think Twice,FDN,Foundations,165,foil,common,1,101202,d88faaa1-eb41-40f7-991c-5c06e1138f3d,0.03,false,false,near_mint,en,USD
|
||||
Grow from the Ashes,FDN,Foundations,225,foil,common,1,101502,42525f8a-aee7-4811-8f05-471b559c2c4a,0.07,false,false,near_mint,en,USD
|
||||
Spitfire Lagac,FDN,Foundations,208,foil,common,1,101496,30f600cd-b696-4f49-9cbc-5a33aa43d04c,0.05,false,false,near_mint,en,USD
|
||||
Abyssal Harvester,FDN,Foundations,54,foil,rare,1,101342,f2e0f538-5825-47e9-883c-3ec6fd5b25ea,3.18,false,false,near_mint,en,USD
|
||||
Sanguine Syphoner,FDN,Foundations,68,foil,common,1,101582,b1daf5bb-c8e9-4e79-a532-ca92a9a885cd,0.19,false,false,near_mint,en,USD
|
||||
Goldvein Pick,FDN,Foundations,253,foil,common,1,101572,a241317d-2277-467e-a8f9-aa71c944e244,0.06,false,false,near_mint,en,USD
|
||||
Goblin Negotiation,FDN,Foundations,88,foil,uncommon,1,101335,f2016585-e26c-4d13-b09f-af6383c192f7,0.14,false,false,near_mint,en,USD
|
||||
Banishing Light,FDN,Foundations,138,foil,common,1,101613,e38dc3b3-1629-491b-8afd-0e7a9a857713,0.05,false,false,near_mint,en,USD
|
||||
Dauntless Veteran,FDN,Foundations,8,foil,uncommon,1,100704,7a136f26-ac66-407f-b389-357222d2c4a2,0.06,false,false,near_mint,en,USD
|
||||
Run Away Together,FDN,Foundations,162,foil,common,1,101614,e598eb7b-10dc-49e6-ac60-2fefa987173e,0.02,false,false,near_mint,en,USD
|
||||
"Tatyova, Benthic Druid",FDN,Foundations,247,foil,uncommon,1,101301,eabc978a-0666-472d-bdc6-d4b29d29eca4,0.14,false,false,near_mint,en,USD
|
||||
"Balmor, Battlemage Captain",FDN,Foundations,237,foil,uncommon,1,100142,0b45ab13-9bb6-48af-8b37-d97b25801ac8,0.13,false,false,near_mint,en,USD
|
||||
Involuntary Employment,FDN,Foundations,203,foil,common,1,101622,f3ad3d62-2f24-4562-b3fa-809213dbc4a4,0.03,false,false,near_mint,en,USD
|
||||
"Dwynen, Gilt-Leaf Daen",FDN,Foundations,217,foil,uncommon,1,100086,01c00d7b-7fac-4f8c-a1ea-de2cf4d06627,0.23,false,false,near_mint,en,USD
|
||||
Swiftfoot Boots,FDN,Foundations,258,foil,uncommon,1,100414,41040541-b129-4cf4-9411-09b1d9d32c19,2.03,false,false,near_mint,en,USD
|
||||
Soul-Shackled Zombie,FDN,Foundations,70,foil,common,1,101609,deea5690-6eb2-4353-b917-cbbf840e4e71,0.05,false,false,near_mint,en,USD
|
||||
Fake Your Own Death,FDN,Foundations,174,foil,common,1,101539,693635a6-df50-44c5-9598-0c79b45d4df4,0.09,false,false,near_mint,en,USD
|
||||
Gnarlid Colony,FDN,Foundations,224,foil,common,1,101508,47565d10-96bf-4fb0-820f-f20a44a76b6f,0.05,false,false,near_mint,en,USD
|
||||
Apothecary Stomper,FDN,Foundations,99,foil,common,1,101537,680b7b0c-0e1b-46ce-9917-9fc6e05aa148,0.02,false,false,near_mint,en,USD
|
||||
Rugged Highlands,FDN,Foundations,265,foil,common,1,101400,fd6eaf8e-8881-4d7b-bafc-75e4ca5cbef6,0.05,false,false,near_mint,en,USD
|
||||
Firebrand Archer,FDN,Foundations,196,foil,common,1,101630,fe0312f1-4c98-4b7f-8a34-0059ea80edef,0.13,false,false,near_mint,en,USD
|
||||
Scoured Barrens,FDN,Foundations,266,foil,common,1,100277,2632a4b2-9ca6-4b67-9a99-14f52ad3dc41,0.12,false,false,near_mint,en,USD
|
||||
Courageous Goblin,FDN,Foundations,82,foil,common,1,101566,8db6819c-666a-409d-85a5-b9ac34d8dd2f,0.02,false,false,near_mint,en,USD
|
||||
Jungle Hollow,FDN,Foundations,263,normal,common,1,101224,dc758e14-d370-45e4-bbc5-938fb4d21127,0.07,false,false,near_mint,en,USD
|
||||
Wind-Scarred Crag,FDN,Foundations,271,normal,common,1,100684,759e99df-11a8-4aee-b6bc-344e84e10d94,0.04,false,false,near_mint,en,USD
|
||||
Dismal Backwater,FDN,Foundations,261,normal,common,1,101220,dbb0df36-8467-4a41-8e1c-6c3584d4fd10,0.06,false,false,near_mint,en,USD
|
||||
Bloodfell Caves,FDN,Foundations,259,normal,common,1,100806,8b90dc92-cb66-41d9-89f9-2b6e3cfc8082,0.05,false,false,near_mint,en,USD
|
||||
Rugged Highlands,FDN,Foundations,265,normal,common,1,101400,fd6eaf8e-8881-4d7b-bafc-75e4ca5cbef6,0.05,false,false,near_mint,en,USD
|
||||
Scavenging Ooze,FDN,Foundations,232,normal,rare,1,100808,8c504c23-1e9a-411b-9cfe-4180d0c744f6,0.15,false,false,near_mint,en,USD
|
||||
"Kiora, the Rising Tide",FDN,Foundations,45,normal,rare,1,100762,83f20a32-9f5d-4a68-8995-549e57554da2,1.57,false,false,near_mint,en,USD
|
||||
Curator of Destinies,FDN,Foundations,34,normal,rare,1,100908,9ff79da7-c3f7-4541-87a0-503544c699b5,0.12,false,false,near_mint,en,USD
|
||||
"Loot, Exuberant Explorer",FDN,Foundations,106,normal,rare,1,100131,09980ce6-425b-4e03-94d0-0f02043cb361,4.8,false,false,near_mint,en,USD
|
||||
Micromancer,FDN,Foundations,158,normal,uncommon,1,101274,e6af54ea-b57a-4e50-8e46-1747cca14430,0.07,false,false,near_mint,en,USD
|
||||
"Ruby, Daring Tracker",FDN,Foundations,245,normal,uncommon,1,101405,fe3e7dd2-b66d-4218-9fde-f84bec26b7bf,0.05,false,false,near_mint,en,USD
|
||||
Mild-Mannered Librarian,FDN,Foundations,228,normal,uncommon,1,100515,5389663a-fe25-41b9-8c92-1f4d7721ffc2,0.03,false,false,near_mint,en,USD
|
||||
Guarded Heir,FDN,Foundations,14,normal,uncommon,1,100505,525ba5c7-3ce5-4e52-b8b5-96c9040a6738,0.05,false,false,near_mint,en,USD
|
||||
Garruk's Uprising,FDN,Foundations,220,normal,uncommon,1,100447,4805c303-e73b-443b-a09f-49d2c2c88bb5,0.25,false,false,near_mint,en,USD
|
||||
Vampire Nighthawk,FDN,Foundations,186,normal,uncommon,1,101474,0a1934ab-3171-4fc6-8033-ad998899ba73,0.12,false,false,near_mint,en,USD
|
||||
Soulstone Sanctuary,FDN,Foundations,133,normal,rare,1,100596,642553a7-6d0f-483d-a873-3a703786db42,1.9,false,false,near_mint,en,USD
|
||||
"Balmor, Battlemage Captain",FDN,Foundations,237,normal,uncommon,1,100142,0b45ab13-9bb6-48af-8b37-d97b25801ac8,0.07,false,false,near_mint,en,USD
|
||||
Adventuring Gear,FDN,Foundations,249,normal,uncommon,1,100358,361f9b99-5b5d-40da-b4b9-5ad90f6280ee,0.06,false,false,near_mint,en,USD
|
||||
Grappling Kraken,FDN,Foundations,39,normal,uncommon,1,101165,d1f5cab3-3fc0-448d-8252-cd55abf5b596,0.12,false,false,near_mint,en,USD
|
||||
Quakestrider Ceratops,FDN,Foundations,110,normal,uncommon,1,100120,067f72c2-ead6-4879-bc9d-696c9f87c0b2,0.11,false,false,near_mint,en,USD
|
||||
Genesis Wave,FDN,Foundations,221,normal,rare,1,101177,d46f7ddb-f986-4f1f-b096-ae1a02d0bdc8,0.29,false,false,near_mint,en,USD
|
||||
"Lathril, Blade of the Elves",FDN,Foundations,242,normal,rare,1,100811,8d4e5480-a287-4a25-b855-a26dae555b1c,0.25,false,false,near_mint,en,USD
|
||||
Elvish Archdruid,FDN,Foundations,219,normal,rare,1,100341,341da856-7414-403b-b2e3-4bebd58a5aa4,0.4,false,false,near_mint,en,USD
|
||||
Imprisoned in the Moon,FDN,Foundations,156,normal,uncommon,1,101313,ee28e147-6622-4399-a314-c14a5c912dd0,0.18,false,false,near_mint,en,USD
|
||||
Inspiring Call,FDN,Foundations,226,normal,uncommon,1,100400,3e241642-5172-4437-b694-f6aa159d5cd9,0.15,false,false,near_mint,en,USD
|
||||
Essence Scatter,FDN,Foundations,153,normal,uncommon,1,101226,dd05c850-f91e-4ffb-b4cc-8418d49dad90,0.04,false,false,near_mint,en,USD
|
||||
Exemplar of Light,FDN,Foundations,11,normal,rare,1,100832,920c8fc5-fdd2-446a-a676-5c363f96928f,2.82,false,false,near_mint,en,USD
|
||||
Meteor Golem,FDN,Foundations,256,normal,uncommon,1,101167,d291ea1e-36bc-46b3-b3ae-084fa0ba69eb,0.05,false,false,near_mint,en,USD
|
||||
Swiftfoot Boots,FDN,Foundations,258,normal,uncommon,1,100414,41040541-b129-4cf4-9411-09b1d9d32c19,1.19,false,false,near_mint,en,USD
|
||||
Brazen Scourge,FDN,Foundations,191,normal,uncommon,1,101616,eb84b86c-3276-4fc1-a09d-47de388cb729,0.02,false,false,near_mint,en,USD
|
||||
Sylvan Scavenging,FDN,Foundations,113,normal,rare,1,101100,c35b683c-d3b2-46a1-876a-81b34e8ba2fc,0.25,false,false,near_mint,en,USD
|
||||
Claws Out,FDN,Foundations,6,normal,uncommon,1,100429,4396049c-b976-4b7f-8ecd-564e24ebd631,0.1,false,false,near_mint,en,USD
|
||||
Snakeskin Veil,FDN,Foundations,233,normal,uncommon,1,100645,6cc4c21d-9bdc-4490-9203-17f51db0ddd1,0.08,false,false,near_mint,en,USD
|
||||
Skyship Buccaneer,FDN,Foundations,50,normal,uncommon,1,100587,62958fc3-55dc-4b97-a070-490d6ed27820,0.02,false,false,near_mint,en,USD
|
||||
Arcane Epiphany,FDN,Foundations,29,normal,uncommon,1,100116,06431793-5dfe-4cbf-990b-4bcc960d1f31,0.03,false,false,near_mint,en,USD
|
||||
Brass's Bounty,FDN,Foundations,190,normal,rare,1,100610,65fe7127-b0ec-400f-97f1-6e17ab8e319d,0.14,false,false,near_mint,en,USD
|
||||
Fiendish Panda,FDN,Foundations,120,normal,uncommon,1,100483,4e434d74-cad0-45f5-bc8d-f34aa5e1d879,0.09,false,false,near_mint,en,USD
|
||||
Frenzied Goblin,FDN,Foundations,199,normal,uncommon,1,101602,d5592573-2889-40b1-b1d5-c2802482549a,0.03,false,false,near_mint,en,USD
|
||||
Lunar Insight,FDN,Foundations,46,normal,rare,1,100958,a9a159f6-fecf-4bdd-b2f8-a9665a5cc32d,0.25,false,false,near_mint,en,USD
|
||||
Twinblade Blessing,FDN,Foundations,26,normal,uncommon,1,101310,ecf01cbe-9fcb-4f35-bc6b-2280620b06ff,0.1,false,false,near_mint,en,USD
|
||||
"Tatyova, Benthic Druid",FDN,Foundations,247,normal,uncommon,1,101301,eabc978a-0666-472d-bdc6-d4b29d29eca4,0.06,false,false,near_mint,en,USD
|
||||
Dragon Trainer,FDN,Foundations,84,normal,uncommon,1,100830,91bd75a1-cb54-4e38-9ce1-e8f32a73c6eb,0.04,false,false,near_mint,en,USD
|
||||
Raise the Past,FDN,Foundations,22,normal,rare,1,100641,6c6be129-56da-4fe7-a6bd-6a1d402c09e1,2.27,false,false,near_mint,en,USD
|
||||
Divine Resilience,FDN,Foundations,10,normal,uncommon,1,101347,f3a08245-a535-4d24-b8c0-78759bb9c4b0,0.11,false,false,near_mint,en,USD
|
||||
Bulk Up,FDN,Foundations,80,normal,uncommon,1,100857,977dcc50-da10-4281-b522-9240c1204f5d,0.2,false,false,near_mint,en,USD
|
||||
Diregraf Ghoul,FDN,Foundations,171,normal,uncommon,1,100439,4682012c-d7e0-4257-b538-3de497507464,0.03,false,false,near_mint,en,USD
|
||||
Drake Hatcher,FDN,Foundations,35,normal,rare,1,101071,bcaf4196-6bf3-47fa-b5c7-0e77f45cf820,0.12,false,false,near_mint,en,USD
|
||||
Youthful Valkyrie,FDN,Foundations,149,normal,uncommon,1,100894,9d795f79-c3a5-4ea1-a5cf-1ce73d6837b6,0.14,false,false,near_mint,en,USD
|
||||
Seeker's Folly,FDN,Foundations,69,normal,uncommon,1,101067,bc359da6-8b7f-45ec-b530-ce159fc35953,0.06,false,false,near_mint,en,USD
|
||||
Heroic Reinforcements,FDN,Foundations,241,normal,uncommon,1,100631,6a05e8d5-c2ad-489a-888d-22622886b620,0.04,false,false,near_mint,en,USD
|
||||
Inspiration from Beyond,FDN,Foundations,43,normal,uncommon,1,101033,b636fe95-664f-4fb1-aab9-28856edeccd6,0.04,false,false,near_mint,en,USD
|
||||
"Dwynen, Gilt-Leaf Daen",FDN,Foundations,217,normal,uncommon,1,100086,01c00d7b-7fac-4f8c-a1ea-de2cf4d06627,0.14,false,false,near_mint,en,USD
|
||||
Twinflame Tyrant,FDN,Foundations,97,normal,mythic,1,100228,1eb34f51-0bd2-43c3-af95-2ce8dabcc7bb,17.77,false,false,near_mint,en,USD
|
||||
Sun-Blessed Healer,FDN,Foundations,25,normal,uncommon,1,100332,323d029e-9a88-4188-b3a4-38ef32cffc9f,0.09,false,false,near_mint,en,USD
|
||||
Seismic Rupture,FDN,Foundations,205,normal,uncommon,1,100268,2519a51a-26a0-4884-9ba8-9db135c9ee49,0.02,false,false,near_mint,en,USD
|
||||
Slumbering Cerberus,FDN,Foundations,94,normal,uncommon,1,100892,9d06faa8-201d-45db-b398-ad56f7b01848,0.03,false,false,near_mint,en,USD
|
||||
Tragic Banshee,FDN,Foundations,73,normal,uncommon,1,100324,30df3e33-2f17-4067-99f1-5db6b0f41fd4,0.03,false,false,near_mint,en,USD
|
||||
Stromkirk Bloodthief,FDN,Foundations,185,normal,uncommon,1,97176,485d6a5a-2054-47d5-91b8-71ce308ed4dc,0.04,false,false,near_mint,en,USD
|
||||
Blanchwood Armor,FDN,Foundations,213,normal,uncommon,1,100237,1fd7ec1a-dafa-42ca-bc25-f6848fb03f60,0.07,false,false,near_mint,en,USD
|
||||
Spectral Sailor,FDN,Foundations,164,normal,uncommon,1,100100,03a49535-c5f3-4a6f-b333-7ac7bffdc9ae,0.06,false,false,near_mint,en,USD
|
||||
Extravagant Replication,FDN,Foundations,154,normal,rare,1,100634,6a41dfae-bc7e-4105-8f7e-fd0109197ad8,0.43,false,false,near_mint,en,USD
|
||||
Electroduplicate,FDN,Foundations,85,normal,rare,1,100976,abb06b1c-5d4e-49b9-9c4a-e60ab656a257,0.3,false,false,near_mint,en,USD
|
||||
Angel of Finality,FDN,Foundations,136,normal,uncommon,1,101057,baaabd52-3aa9-4e2f-9369-d4db8b405ba8,0.07,false,false,near_mint,en,USD
|
||||
Battlesong Berserker,FDN,Foundations,78,normal,uncommon,1,100917,a1f8b199-5d62-485f-b1c3-b30aa550595b,0.03,false,false,near_mint,en,USD
|
||||
Swiftblade Vindicator,FDN,Foundations,246,normal,rare,1,101372,f94618ec-000c-4371-b925-05ff82bfe221,0.12,false,false,near_mint,en,USD
|
||||
Dauntless Veteran,FDN,Foundations,8,normal,uncommon,1,100704,7a136f26-ac66-407f-b389-357222d2c4a2,0.05,false,false,near_mint,en,USD
|
||||
Hero's Downfall,FDN,Foundations,175,normal,uncommon,1,97185,ad2c01d9-8f54-46c0-9dc9-d4d4764ce1c9,0.1,false,false,near_mint,en,USD
|
||||
Resolute Reinforcements,FDN,Foundations,145,normal,uncommon,1,100841,940f3989-77cc-49a9-92e0-095a75d80f0f,0.09,false,false,near_mint,en,USD
|
||||
Zombify,FDN,Foundations,187,normal,uncommon,1,101225,dc798e6f-13c4-457c-b052-b7b65bc83cfe,0.09,false,false,near_mint,en,USD
|
||||
Fiery Annihilation,FDN,Foundations,86,normal,uncommon,1,100523,54fe00aa-d284-48f9-b5a2-1bd4c5fa8e58,0.07,false,false,near_mint,en,USD
|
||||
Clinquant Skymage,FDN,Foundations,33,normal,uncommon,1,100357,36012810-0e83-4640-8ba7-7262229f1b84,0.05,false,false,near_mint,en,USD
|
||||
Consuming Aberration,FDN,Foundations,238,normal,rare,1,101066,bc2b28fd-66b0-457c-80ea-7caed2cc7926,0.16,false,false,near_mint,en,USD
|
||||
Fishing Pole,FDN,Foundations,128,normal,uncommon,1,101128,c95ab836-3277-4223-9aaa-ef2c77256b65,0.07,false,false,near_mint,en,USD
|
||||
Felling Blow,FDN,Foundations,105,normal,uncommon,1,100854,96948ae3-b15d-4d6d-aa73-9f52084cd903,0.05,false,false,near_mint,en,USD
|
||||
Abrade,FDN,Foundations,188,normal,uncommon,1,100522,548947dc-a5ca-43b5-9531-bcef20fa4ae5,0.09,false,false,near_mint,en,USD
|
||||
Spinner of Souls,FDN,Foundations,112,normal,rare,1,101358,f50a8dec-b079-4192-9098-6cdc1026c693,0.66,false,false,near_mint,en,USD
|
||||
Vampire Gourmand,FDN,Foundations,74,normal,uncommon,1,100827,917514c0-9cd5-4b97-85b9-c4f753560ad4,0.09,false,false,near_mint,en,USD
|
||||
Needletooth Pack,FDN,Foundations,108,normal,uncommon,1,100868,993c1679-e02b-44f2-b34e-12fd6b5142e9,0.05,false,false,near_mint,en,USD
|
||||
Burnished Hart,FDN,Foundations,250,normal,uncommon,1,100609,65ebbff0-fbe6-4310-a33f-e00bb2534979,0.06,false,false,near_mint,en,USD
|
||||
Arbiter of Woe,FDN,Foundations,55,normal,uncommon,1,101008,b2496c4a-df03-4583-bd76-f98ed5cb61ee,0.06,false,false,near_mint,en,USD
|
||||
Good-Fortune Unicorn,FDN,Foundations,240,normal,uncommon,1,101300,eabbe163-2b15-42e3-89ce-7363e6250d3a,0.1,false,false,near_mint,en,USD
|
||||
Reassembling Skeleton,FDN,Foundations,182,normal,uncommon,1,100291,28e84b1b-1c05-4e1b-93b8-9cc2ca73509d,0.08,false,false,near_mint,en,USD
|
||||
Reclamation Sage,FDN,Foundations,231,normal,uncommon,1,100197,1918ea65-ab7f-4d40-97fd-a656c892a2a1,0.14,false,false,near_mint,en,USD
|
||||
Leyline Axe,FDN,Foundations,129,normal,rare,1,101052,b9c03336-a321-4c06-94d1-809f328fabd8,3.17,false,false,near_mint,en,USD
|
||||
An Offer You Can't Refuse,FDN,Foundations,160,normal,uncommon,1,100948,a829747f-cf9b-4d81-ba66-9f0630ed4565,0.99,false,false,near_mint,en,USD
|
||||
Goblin Negotiation,FDN,Foundations,88,normal,uncommon,1,101335,f2016585-e26c-4d13-b09f-af6383c192f7,0.09,false,false,near_mint,en,USD
|
||||
Empyrean Eagle,FDN,Foundations,239,normal,uncommon,1,100533,577e99a7-4a55-4314-8f08-2ae0c33b85c7,0.08,false,false,near_mint,en,USD
|
||||
Solemn Simulacrum,FDN,Foundations,257,normal,rare,1,100514,5383f45e-3da2-40fb-beee-801448bbb60f,0.3,false,false,near_mint,en,USD
|
||||
Crystal Barricade,FDN,Foundations,7,normal,rare,1,100822,905d3e02-ea06-45e7-9adb-c8e7583323a2,1.24,false,false,near_mint,en,USD
|
||||
Hidetsugu's Second Rite,FDN,Foundations,202,normal,uncommon,1,100577,609421da-8d89-4365-b18b-778832d91482,0.04,false,false,near_mint,en,USD
|
||||
Affectionate Indrik,FDN,Foundations,211,normal,uncommon,1,100310,2da8347d-06a4-46e0-a55e-cc2da4660263,0.02,false,false,near_mint,en,USD
|
||||
Infernal Vessel,FDN,Foundations,63,normal,uncommon,1,101560,877b6330-2d0b-4f2f-a848-f10b06fb4ef5,0.06,false,false,near_mint,en,USD
|
||||
"Zimone, Paradox Sculptor",FDN,Foundations,126,normal,mythic,1,100241,20ccbfdd-ddae-440c-9bc0-38b15a56fdd1,2.13,false,false,near_mint,en,USD
|
||||
High-Society Hunter,FDN,Foundations,61,normal,rare,1,100501,51da4a4b-ea12-4169-a7cf-eb4427f13e84,0.64,false,false,near_mint,en,USD
|
||||
Heraldic Banner,FDN,Foundations,254,normal,uncommon,1,100678,743ea709-dbb3-4db8-a2ce-544f47eb6339,0.24,false,false,near_mint,en,USD
|
||||
Wardens of the Cycle,FDN,Foundations,125,normal,uncommon,1,100761,83ea9b2c-5723-4eff-88ac-6669975939e3,0.07,false,false,near_mint,en,USD
|
||||
Preposterous Proportions,FDN,Foundations,109,normal,rare,1,100983,acb65189-60e4-42e0-9fb1-da6b716b91d7,0.94,false,false,near_mint,en,USD
|
||||
Savannah Lions,FDN,Foundations,146,normal,uncommon,1,97184,9c9ac1bc-cdf3-4fa6-8319-a7ea164e9e47,0.04,false,false,near_mint,en,USD
|
||||
Secluded Courtyard,FDN,Foundations,267,normal,uncommon,1,101161,d13373d2-139b-48c7-a8c9-828cefc4f150,0.12,false,false,near_mint,en,USD
|
||||
Ajani's Pridemate,FDN,Foundations,135,normal,uncommon,1,100255,222c1a68-e34c-4103-b1be-17d4ceaef6ce,0.06,false,false,near_mint,en,USD
|
||||
"Arahbo, the First Fang",FDN,Foundations,2,normal,rare,1,100503,524a5d93-26ed-436d-a437-dc9460acce98,1.0,false,false,near_mint,en,USD
|
||||
Authority of the Consuls,FDN,Foundations,137,normal,rare,1,100425,42ce2d7f-5924-47c0-b5ed-dacf9f9617a0,5.3,false,false,near_mint,en,USD
|
||||
Nine-Lives Familiar,FDN,Foundations,321,normal,rare,1,100060,6cc1623f-370d-42b5-88a2-039f31e9be0b,2.67,false,false,near_mint,en,USD
|
||||
Ajani's Pridemate,FDN,Foundations,293,foil,uncommon,1,101180,d4cfb9bc-4273-4e5f-a7ac-2006a8345a4e,0.38,false,false,near_mint,en,USD
|
||||
Helpful Hunter,FDN,Foundations,16,foil,common,1,97172,1b9a0e91-80b5-428f-8f08-931d0631be14,1.61,false,false,near_mint,en,USD
|
||||
Felidar Savior,FDN,Foundations,12,foil,common,1,97191,cd092b14-d72f-4de0-8f19-1338661b9e3b,0.05,false,false,near_mint,en,USD
|
||||
Thrill of Possibility,FDN,Foundations,210,normal,common,3,101561,882b348c-076b-41d8-b505-063480636669,0.03,false,false,near_mint,en,USD
|
||||
Lightshell Duo,FDN,Foundations,157,normal,common,7,101063,bb75315c-ea8f-4eb0-899e-c73ef75fc396,0.04,false,false,near_mint,en,USD
|
||||
Mischievous Pup,FDN,Foundations,144,normal,uncommon,2,100670,7214d984-6400-44d7-bde6-57d96b606e78,0.04,false,false,near_mint,en,USD
|
||||
Swiftwater Cliffs,FDN,Foundations,268,normal,common,3,101389,fb88667d-7088-4889-960f-317486ebe856,0.03,false,false,near_mint,en,USD
|
||||
Hare Apparent,FDN,Foundations,15,normal,common,3,100907,9fc6f0e9-eb5f-4bc0-b3d7-756644b66d12,3.62,false,false,near_mint,en,USD
|
||||
Dazzling Angel,FDN,Foundations,9,normal,common,3,101468,027dc444-e544-4693-8653-3dcdda530162,0.1,false,false,near_mint,en,USD
|
||||
Bigfin Bouncer,FDN,Foundations,31,normal,common,3,100882,9b1d5b76-b07e-45c6-800d-4cfce085164f,0.02,false,false,near_mint,en,USD
|
||||
Ambush Wolf,FDN,Foundations,98,normal,common,4,101492,2903832c-318e-42ab-bf58-c682ec2f7afd,0.05,false,false,near_mint,en,USD
|
||||
Healer's Hawk,FDN,Foundations,142,normal,common,3,101595,cc8e4563-04bb-46b5-835e-64ba11c0e972,0.09,false,false,near_mint,en,USD
|
||||
Rune-Sealed Wall,FDN,Foundations,49,normal,uncommon,2,101212,da0f147b-95ed-4f32-9b46-6a633ae31976,0.15,false,false,near_mint,en,USD
|
||||
Pilfer,FDN,Foundations,181,normal,common,4,101564,8c7c88b5-6d09-453b-b9c1-7dcbba8f1080,0.03,false,false,near_mint,en,USD
|
||||
Stab,FDN,Foundations,71,normal,common,3,101538,6859a5ba-1c1c-4631-bba8-f9900b827178,0.04,false,false,near_mint,en,USD
|
||||
Heartfire Immolator,FDN,Foundations,201,normal,uncommon,2,100390,3ca38f4d-01f5-4a02-9000-01261a440dbf,0.03,false,false,near_mint,en,USD
|
||||
Marauding Blight-Priest,FDN,Foundations,178,normal,common,3,101528,5f70dafc-c638-4ec0-ab5b-62998f752720,0.12,false,false,near_mint,en,USD
|
||||
Broken Wings,FDN,Foundations,214,normal,common,3,100584,61f9cbeb-cc9c-4562-be65-8a77053faefe,0.02,false,false,near_mint,en,USD
|
||||
Firespitter Whelp,FDN,Foundations,197,normal,uncommon,2,100463,4b3a4c7d-3126-4bde-9dca-cb6a1e2f37c9,0.15,false,false,near_mint,en,USD
|
||||
Make Your Move,FDN,Foundations,143,normal,common,3,101546,7368f861-3288-4645-90a7-ca35d6da3721,0.03,false,false,near_mint,en,USD
|
||||
Treetop Snarespinner,FDN,Foundations,114,normal,common,4,101562,88e68fa3-159d-49a6-8ac6-afc9bd6f1718,0.06,false,false,near_mint,en,USD
|
||||
Vengeful Bloodwitch,FDN,Foundations,76,normal,uncommon,2,97189,bd0c12dd-f138-45c0-9614-d83a1d8e8399,0.17,false,false,near_mint,en,USD
|
||||
Evolving Wilds,FDN,Foundations,262,normal,common,4,100376,3a0b9356-5b91-4542-8802-f0f7275238e1,0.06,false,false,near_mint,en,USD
|
||||
Bite Down,FDN,Foundations,212,normal,common,3,101625,f8d70b3b-f6f9-4b3c-ad70-0ce369e812b5,0.04,false,false,near_mint,en,USD
|
||||
Elfsworn Giant,FDN,Foundations,103,normal,common,3,100497,5128a5be-ffa6-4998-8488-872d80b24cb2,0.06,false,false,near_mint,en,USD
|
||||
Apothecary Stomper,FDN,Foundations,99,normal,common,3,101537,680b7b0c-0e1b-46ce-9917-9fc6e05aa148,0.05,false,false,near_mint,en,USD
|
||||
Axgard Cavalry,FDN,Foundations,189,normal,common,3,101631,fe3cc41a-adae-4c9b-b4d3-03f3ca862fed,0.03,false,false,near_mint,en,USD
|
||||
Wary Thespian,FDN,Foundations,235,normal,common,3,101574,a3d62d04-0974-4cb5-9a35-5e996c6456e2,0.01,false,false,near_mint,en,USD
|
||||
Fleeting Flight,FDN,Foundations,13,normal,common,3,101513,55139100-9342-41fd-b10a-8e9932e605d4,0.04,false,false,near_mint,en,USD
|
||||
Quick-Draw Katana,FDN,Foundations,130,normal,common,3,101540,69beec98-c89c-4673-953c-8b3ef3d81560,0.07,false,false,near_mint,en,USD
|
||||
Goblin Surprise,FDN,Foundations,200,normal,common,3,101512,527dd5d4-5f72-40bb-8a9d-1f5ac3f81e2e,0.05,false,false,near_mint,en,USD
|
||||
Sower of Chaos,FDN,Foundations,95,normal,common,4,101556,7ff50606-491c-4946-8d03-719b01cfad77,0.01,false,false,near_mint,en,USD
|
||||
Involuntary Employment,FDN,Foundations,203,normal,common,4,101622,f3ad3d62-2f24-4562-b3fa-809213dbc4a4,0.06,false,false,near_mint,en,USD
|
||||
Burst Lightning,FDN,Foundations,192,normal,common,3,100994,aec5d380-d354-4750-931a-6c91853e2edc,0.08,false,false,near_mint,en,USD
|
||||
Banishing Light,FDN,Foundations,138,normal,common,4,101613,e38dc3b3-1629-491b-8afd-0e7a9a857713,0.03,false,false,near_mint,en,USD
|
||||
Blossoming Sands,FDN,Foundations,260,normal,common,2,100364,37676ed8-588c-4bca-8065-874b74d84807,0.05,false,false,near_mint,en,USD
|
||||
Felidar Savior,FDN,Foundations,12,normal,common,3,97191,cd092b14-d72f-4de0-8f19-1338661b9e3b,0.02,false,false,near_mint,en,USD
|
||||
Revenge of the Rats,FDN,Foundations,67,normal,uncommon,2,100232,1f463c55-39a0-4f2f-aae3-0c5540bde5b7,0.12,false,false,near_mint,en,USD
|
||||
Armasaur Guide,FDN,Foundations,3,normal,common,3,101591,c80fc380-0499-4499-8a60-c43844c02c9b,0.03,false,false,near_mint,en,USD
|
||||
Campus Guide,FDN,Foundations,251,normal,common,3,101504,43c59814-3167-4b05-bb85-6c736f3956a4,0.02,false,false,near_mint,en,USD
|
||||
Dreadwing Scavenger,FDN,Foundations,118,normal,uncommon,2,101252,e24d838b-ab48-410a-9a50-dbfea5da089b,0.04,false,false,near_mint,en,USD
|
||||
Gleaming Barrier,FDN,Foundations,252,normal,common,3,101479,1b49b009-e6f2-494a-9235-f5c25c2d70a9,0.06,false,false,near_mint,en,USD
|
||||
Scoured Barrens,FDN,Foundations,266,normal,common,2,100277,2632a4b2-9ca6-4b67-9a99-14f52ad3dc41,0.07,false,false,near_mint,en,USD
|
||||
Erudite Wizard,FDN,Foundations,37,normal,common,3,100835,9273c417-0fcd-4273-b24e-afff76336d0c,0.01,false,false,near_mint,en,USD
|
||||
Gorehorn Raider,FDN,Foundations,89,normal,common,3,101551,78ce6c40-3452-4aa0-a45b-dbfd70f8d220,0.02,false,false,near_mint,en,USD
|
||||
Cackling Prowler,FDN,Foundations,101,normal,common,3,101481,1bd8e971-c075-4203-8d83-c28f22d4f9b9,0.03,false,false,near_mint,en,USD
|
||||
Burglar Rat,FDN,Foundations,170,normal,common,4,101608,de1c8758-ce3d-49cf-8173-c0eb46f5e7bc,0.05,false,false,near_mint,en,USD
|
||||
Mocking Sprite,FDN,Foundations,159,normal,common,3,101624,f6792f63-b651-497d-8aa5-cddf4cedeca8,0.03,false,false,near_mint,en,USD
|
||||
Cathar Commando,FDN,Foundations,139,normal,common,3,100204,19cf024d-edb6-4a79-8676-73f8db0cdf1f,0.06,false,false,near_mint,en,USD
|
||||
Hungry Ghoul,FDN,Foundations,62,normal,common,3,100701,790f9433-7565-4f7f-88e8-8af762ea0296,0.04,false,false,near_mint,en,USD
|
||||
Vampire Soulcaller,FDN,Foundations,75,normal,common,3,101495,2d076293-3b45-4878-8f67-978927cc1f68,0.04,false,false,near_mint,en,USD
|
||||
Exsanguinate,FDN,Foundations,173,normal,uncommon,1,101330,f11d7311-4066-4a5d-ba28-9857fa707a0b,0.4,false,false,near_mint,en,USD
|
||||
Fanatical Firebrand,FDN,Foundations,195,normal,common,3,101598,d1296316-7781-4e98-95e6-7020648be6a5,0.03,false,false,near_mint,en,USD
|
||||
Sanguine Syphoner,FDN,Foundations,68,normal,common,4,101582,b1daf5bb-c8e9-4e79-a532-ca92a9a885cd,0.07,false,false,near_mint,en,USD
|
||||
Boltwave,FDN,Foundations,79,normal,uncommon,2,100810,8d1ec351-5e70-4eb2-b590-6bff94ef8178,4.08,false,false,near_mint,en,USD
|
||||
Nessian Hornbeetle,FDN,Foundations,229,normal,uncommon,2,100395,3d4d93de-85c6-4653-8ddd-d8bf21516d44,0.05,false,false,near_mint,en,USD
|
||||
Goldvein Pick,FDN,Foundations,253,normal,common,3,101572,a241317d-2277-467e-a8f9-aa71c944e244,0.06,false,false,near_mint,en,USD
|
||||
Icewind Elemental,FDN,Foundations,42,normal,common,3,101629,fd0eba76-3829-408b-828f-0b223c884728,0.05,false,false,near_mint,en,USD
|
||||
Fleeting Distraction,FDN,Foundations,155,normal,common,3,101587,c0b86a7b-4912-43a7-ab89-c3432385baa1,0.02,false,false,near_mint,en,USD
|
||||
Faebloom Trick,FDN,Foundations,38,normal,uncommon,2,100148,0c3bee8f-f5be-4404-a696-c902637799c3,0.17,false,false,near_mint,en,USD
|
||||
Brineborn Cutthroat,FDN,Foundations,152,normal,uncommon,2,100986,acf7aafb-931f-49e5-8691-eab8cb34b05e,0.02,false,false,near_mint,en,USD
|
||||
Gutless Plunderer,FDN,Foundations,60,normal,common,3,101567,909d7778-c7f8-4fa4-89f2-8b32e86e96e4,0.05,false,false,near_mint,en,USD
|
||||
Thornwood Falls,FDN,Foundations,269,normal,common,2,100424,42799f51-0f8c-444b-974e-dae281a5c697,0.05,false,false,near_mint,en,USD
|
||||
Tranquil Cove,FDN,Foundations,270,normal,common,2,100719,7c9cabca-5bcc-4b97-b2ac-a345ad3ee43c,0.06,false,false,near_mint,en,USD
|
||||
Fake Your Own Death,FDN,Foundations,174,normal,common,3,101539,693635a6-df50-44c5-9598-0c79b45d4df4,0.05,false,false,near_mint,en,USD
|
||||
Crypt Feaster,FDN,Foundations,59,normal,common,4,100382,3b072811-998a-4a71-b59c-6afecc0dc4b6,0.03,false,false,near_mint,en,USD
|
||||
Incinerating Blast,FDN,Foundations,90,normal,common,3,101603,d58e20ab-c5ca-4295-884d-78efdaa83243,0.03,false,false,near_mint,en,USD
|
||||
Refute,FDN,Foundations,48,normal,common,3,100368,38806934-dd9c-4ad4-a59c-a16dce03a14a,0.06,false,false,near_mint,en,USD
|
||||
Tolarian Terror,FDN,Foundations,167,normal,common,3,100270,2569d4f3-55ed-4f99-9592-34c7df0aab72,0.09,false,false,near_mint,en,USD
|
||||
Joust Through,FDN,Foundations,19,normal,uncommon,2,100767,846adb38-f9bb-4fed-b8ed-36ec7885f989,0.05,false,false,near_mint,en,USD
|
||||
Bake into a Pie,FDN,Foundations,169,normal,common,3,101494,2ab0e660-86a3-4b92-82fa-77dcb5db947d,0.03,false,false,near_mint,en,USD
|
||||
Soul-Shackled Zombie,FDN,Foundations,70,normal,common,4,101609,deea5690-6eb2-4353-b917-cbbf840e4e71,0.04,false,false,near_mint,en,USD
|
||||
Perforating Artist,FDN,Foundations,124,normal,uncommon,2,100674,72980409-53f0-43c1-965e-06f22e7bb608,0.1,false,false,near_mint,en,USD
|
||||
Serra Angel,FDN,Foundations,147,normal,uncommon,2,100391,3cee9303-9d65-45a2-93d4-ef4aba59141b,0.05,false,false,near_mint,en,USD
|
||||
Squad Rallier,FDN,Foundations,24,normal,common,3,101534,65e1ee86-6f08-4aa0-bf63-ae12028ef080,0.04,false,false,near_mint,en,USD
|
||||
Elementalist Adept,FDN,Foundations,36,normal,common,3,101605,d9768cc6-8f53-4922-ae32-376a2f32d719,0.02,false,false,near_mint,en,USD
|
||||
Elvish Regrower,FDN,Foundations,104,normal,uncommon,2,100278,2694e3cd-26ed-4a10-ae55-fb84d7800253,0.09,false,false,near_mint,en,USD
|
||||
Infestation Sage,FDN,Foundations,64,normal,common,3,101601,d40c73de-7a5f-46f2-a70b-449bc8ecfe24,0.07,false,false,near_mint,en,USD
|
||||
Inspiring Paladin,FDN,Foundations,18,normal,common,3,101472,0763be06-25b2-4d6b-ab33-a1af85aeb443,0.02,false,false,near_mint,en,USD
|
||||
Luminous Rebuke,FDN,Foundations,20,normal,common,3,101529,621839e1-2756-4cdc-a25c-5f76ea98dd87,0.07,false,false,near_mint,en,USD
|
||||
Gnarlid Colony,FDN,Foundations,224,normal,common,3,101508,47565d10-96bf-4fb0-820f-f20a44a76b6f,0.02,false,false,near_mint,en,USD
|
||||
Sure Strike,FDN,Foundations,209,normal,common,3,101525,5de6a1e4-5c66-43e6-9f2a-2635bdab03f6,0.03,false,false,near_mint,en,USD
|
||||
Helpful Hunter,FDN,Foundations,16,normal,common,3,97172,1b9a0e91-80b5-428f-8f08-931d0631be14,0.14,false,false,near_mint,en,USD
|
||||
Goblin Boarders,FDN,Foundations,87,normal,common,3,101506,4409a063-bf2a-4a49-803e-3ce6bd474353,0.04,false,false,near_mint,en,USD
|
||||
Macabre Waltz,FDN,Foundations,177,normal,common,3,101509,4d1f3c84-89ba-4426-a80b-d524f172c912,0.03,false,false,near_mint,en,USD
|
||||
Grow from the Ashes,FDN,Foundations,225,normal,common,3,101502,42525f8a-aee7-4811-8f05-471b559c2c4a,0.03,false,false,near_mint,en,USD
|
||||
Stroke of Midnight,FDN,Foundations,148,normal,uncommon,2,100970,ab135925-d924-456d-851a-6ccdaaf27271,0.17,false,false,near_mint,en,USD
|
||||
Eaten Alive,FDN,Foundations,172,normal,common,3,100216,1c4f7b20-b2a8-498c-8c36-dc296863b0b9,0.02,false,false,near_mint,en,USD
|
||||
Aetherize,FDN,Foundations,151,normal,uncommon,2,100225,1e5530fc-0291-4a17-b048-c5d24e6f51d8,0.17,false,false,near_mint,en,USD
|
||||
Giant Growth,FDN,Foundations,223,normal,common,4,101073,bd0bf74e-14c1-4428-88d8-2181a080b5d0,0.03,false,false,near_mint,en,USD
|
||||
Billowing Shriekmass,FDN,Foundations,56,normal,uncommon,2,100711,7b3587a9-0667-4d53-807b-c437bcb1d7b3,0.02,false,false,near_mint,en,USD
|
||||
Think Twice,FDN,Foundations,165,normal,common,4,101202,d88faaa1-eb41-40f7-991c-5c06e1138f3d,0.05,false,false,near_mint,en,USD
|
||||
Beast-Kin Ranger,FDN,Foundations,100,normal,common,3,100082,0102e0be-5783-4825-9489-713b1b1df0b2,0.05,false,false,near_mint,en,USD
|
||||
Spitfire Lagac,FDN,Foundations,208,normal,common,4,101496,30f600cd-b696-4f49-9cbc-5a33aa43d04c,0.02,false,false,near_mint,en,USD
|
||||
Aegis Turtle,FDN,Foundations,150,normal,common,3,101590,c7f2014a-fbc9-447c-a440-e06d01066bb9,0.08,false,false,near_mint,en,USD
|
||||
Firebrand Archer,FDN,Foundations,196,normal,common,3,101630,fe0312f1-4c98-4b7f-8a34-0059ea80edef,0.05,false,false,near_mint,en,USD
|
||||
Shivan Dragon,FDN,Foundations,206,normal,uncommon,2,100236,1fcff1e0-2745-448d-a27b-e31719e222e9,0.05,false,false,near_mint,en,USD
|
||||
Cephalid Inkmage,FDN,Foundations,32,normal,uncommon,2,101040,b7e47680-18c7-4ffb-aac4-c5db6e7095ba,0.05,false,false,near_mint,en,USD
|
||||
Prideful Parent,FDN,Foundations,21,normal,common,3,97188,b742117a-8a72-43b9-b05d-274829d138a2,0.04,false,false,near_mint,en,USD
|
||||
Uncharted Voyage,FDN,Foundations,53,normal,common,4,101611,e0846820-e595-4743-8a28-29c57d728677,0.01,false,false,near_mint,en,USD
|
||||
Eager Trufflesnout,FDN,Foundations,102,normal,uncommon,2,100940,a6e8433d-eb2a-43d1-b59b-7d70ff97c8e7,0.04,false,false,near_mint,en,USD
|
||||
Juggernaut,FDN,Foundations,255,normal,uncommon,2,101351,f4468fff-cd6f-428c-b7a0-ff89f5bbea2e,0.07,false,false,near_mint,en,USD
|
||||
Llanowar Elves,FDN,Foundations,227,normal,common,3,95583,6a0b230b-d391-4998-a3f7-7b158a0ec2cd,0.15,false,false,near_mint,en,USD
|
||||
Overrun,FDN,Foundations,230,normal,uncommon,2,100220,1d8e9cbb-8bf4-4a48-a58e-79deb3abdf7f,0.14,false,false,near_mint,en,USD
|
||||
Crackling Cyclops,FDN,Foundations,83,normal,common,3,101541,6e5b899a-52f7-471b-ad50-4fa6566758fd,0.01,false,false,near_mint,en,USD
|
||||
Mischievous Mystic,FDN,Foundations,47,normal,uncommon,2,100242,20d89cec-528b-4b2a-87db-e11ce0000622,0.14,false,false,near_mint,en,USD
|
||||
Witness Protection,FDN,Foundations,168,normal,common,3,101621,f231e981-0069-43ce-ac1c-c85ced613e93,0.08,false,false,near_mint,en,USD
|
||||
Dwynen's Elite,FDN,Foundations,218,normal,common,3,100800,89d94c28-ea2e-4a3d-935f-6b2d9f2efc7a,0.05,false,false,near_mint,en,USD
|
||||
Bushwhack,FDN,Foundations,215,normal,common,3,101469,03ebdb36-55e0-49dd-a514-785fbeb4ae19,0.1,false,false,near_mint,en,USD
|
||||
Run Away Together,FDN,Foundations,162,normal,common,3,101614,e598eb7b-10dc-49e6-ac60-2fefa987173e,0.05,false,false,near_mint,en,USD
|
||||
Strongbox Raider,FDN,Foundations,96,normal,uncommon,2,101006,b2223eb8-59f9-489b-a3f3-b6496218cb79,0.02,false,false,near_mint,en,USD
|
||||
Vanguard Seraph,FDN,Foundations,28,normal,common,4,101503,4329c861-fc16-4a96-9c03-25af6ac2adc8,0.06,false,false,near_mint,en,USD
|
||||
Self-Reflection,FDN,Foundations,163,normal,uncommon,2,101247,e1e6abc9-25b2-4d51-b519-2525079eab51,0.04,false,false,near_mint,en,USD
|
||||
Strix Lookout,FDN,Foundations,52,normal,common,3,101627,fbd2422e-8e84-4c39-af29-3b4d38baee63,0.03,false,false,near_mint,en,USD
|
||||
Cat Collector,FDN,Foundations,4,normal,uncommon,2,100507,526fe356-bff1-4211-9e88-bf913ac76b1d,0.1,false,false,near_mint,en,USD
|
||||
Courageous Goblin,FDN,Foundations,82,normal,common,3,101566,8db6819c-666a-409d-85a5-b9ac34d8dd2f,0.03,false,false,near_mint,en,USD
|
||||
"Ygra, Eater of All",BLB,Bloomburrow,241,normal,mythic,1,95825,b9ac7673-eae8-4c4b-889e-5025213a6151,11.58,false,false,near_mint,en,USD
|
||||
Lifecreed Duo,BLB,Bloomburrow,20,normal,common,1,95968,ca543405-5e12-48a0-9a77-082ac9bcb2f2,0.06,false,false,near_mint,en,USD
|
||||
Take Out the Trash,BLB,Bloomburrow,156,normal,common,1,95940,7a1c6f00-af4c-4d35-b682-6c0e759df9a5,0.04,false,false,near_mint,en,USD
|
||||
Ravine Raider,BLB,Bloomburrow,106,normal,common,1,96370,874510be-7ecd-4eff-abad-b9594eb4821a,0.02,false,false,near_mint,en,USD
|
||||
Longstalk Brawl,BLB,Bloomburrow,182,normal,common,1,95966,c7ef748c-b5e5-4e7d-bf2e-d3e6c08edb42,0.04,false,false,near_mint,en,USD
|
||||
Valley Floodcaller,BLB,Bloomburrow,79,normal,rare,1,95876,90b12da0-f666-471d-95f5-15d8c9b31c92,2.65,false,false,near_mint,en,USD
|
||||
Bandit's Talent,BLB,Bloomburrow,83,normal,uncommon,1,95917,485dc8d8-9e44-4a0f-9ff6-fa448e232290,0.47,false,false,near_mint,en,USD
|
||||
Brambleguard Veteran,BLB,Bloomburrow,165,normal,uncommon,1,95880,bac9f6f8-6797-4580-9fc4-9a825872e017,0.09,false,false,near_mint,en,USD
|
||||
Mouse Trapper,BLB,Bloomburrow,22,normal,uncommon,1,95948,8ba1bc5a-03e7-44ec-893e-44042cbc02ef,0.04,false,false,near_mint,en,USD
|
||||
Bushy Bodyguard,BLB,Bloomburrow,166,normal,uncommon,1,95997,0de60cf7-fa82-4b6f-9f88-6590fba5c863,0.08,false,false,near_mint,en,USD
|
||||
Valley Mightcaller,BLB,Bloomburrow,202,normal,rare,1,96057,7256451f-0122-452a-88e8-0fb0f6bea3f3,1.01,false,false,near_mint,en,USD
|
||||
Druid of the Spade,BLB,Bloomburrow,170,normal,common,1,96054,6b485cf7-bad0-4824-9ba7-cb112ce4769f,0.02,false,false,near_mint,en,USD
|
||||
Skyskipper Duo,BLB,Bloomburrow,71,normal,common,1,96476,d6844bad-ffbe-4c6e-b438-08562eccea52,0.04,false,false,near_mint,en,USD
|
||||
Osteomancer Adept,BLB,Bloomburrow,103,normal,rare,1,95800,7d8238dd-858f-466c-96de-986bd66861d7,0.36,false,false,near_mint,en,USD
|
||||
Tender Wildguide,BLB,Bloomburrow,196,normal,rare,1,95792,6b8bfa91-adb0-4596-8c16-d8bb64fdb26d,0.49,false,false,near_mint,en,USD
|
||||
Huskburster Swarm,BLB,Bloomburrow,98,normal,uncommon,1,95978,ed2f61d7-4eb0-41c5-8a34-a0793c2abc51,0.13,false,false,near_mint,en,USD
|
||||
Scrapshooter,BLB,Bloomburrow,191,normal,rare,1,96113,c42ab407-e72d-4c48-9a9e-2055b5e71c69,0.38,false,false,near_mint,en,USD
|
||||
Scavenger's Talent,BLB,Bloomburrow,111,normal,rare,1,96084,9a52b7fe-87ae-425b-85fd-b24e6e0395f1,1.54,false,false,near_mint,en,USD
|
||||
Valley Rotcaller,BLB,Bloomburrow,119,normal,rare,1,95781,4da80a9a-b1d5-4fc5-92f7-36946195d0c7,1.45,false,false,near_mint,en,USD
|
||||
Thornplate Intimidator,BLB,Bloomburrow,117,normal,common,1,96019,42f66c4a-feaa-4ba6-aa56-955b43329a9e,0.02,false,false,near_mint,en,USD
|
||||
Bakersbane Duo,BLB,Bloomburrow,163,normal,common,1,96035,5309354f-1ff4-4fa9-9141-01ea2f7588ab,0.1,false,false,near_mint,en,USD
|
||||
Shore Up,BLB,Bloomburrow,69,normal,common,1,96277,4dc3b49e-3674-494c-bdea-4374cefd10f4,0.08,false,false,near_mint,en,USD
|
||||
Emberheart Challenger,BLB,Bloomburrow,133,normal,rare,1,95888,0035082e-bb86-4f95-be48-ffc87fe5286d,4.13,false,false,near_mint,en,USD
|
||||
"Gev, Scaled Scorch",BLB,Bloomburrow,214,normal,rare,1,96001,131ea976-289e-4f32-896d-27bbfd423ba9,0.37,false,false,near_mint,en,USD
|
||||
Starfall Invocation,BLB,Bloomburrow,34,normal,rare,1,95904,2aea38e6-ec58-4091-b27c-2761bdd12b13,0.88,false,false,near_mint,en,USD
|
||||
Tidecaller Mentor,BLB,Bloomburrow,236,normal,uncommon,1,95859,fa10ffac-7cc2-41ef-b8a0-9431923c0542,0.04,false,false,near_mint,en,USD
|
||||
Jackdaw Savior,BLB,Bloomburrow,18,normal,rare,1,96000,121af600-6143-450a-9f87-12ce4833f1ec,0.27,false,false,near_mint,en,USD
|
||||
"Helga, Skittish Seer",BLB,Bloomburrow,217,normal,mythic,1,95914,40339715-22d0-4f99-822b-a00d9824f27a,2.0,false,false,near_mint,en,USD
|
||||
Long River Lurker,BLB,Bloomburrow,57,normal,uncommon,1,95941,7c267719-cd03-4003-b281-e732d5e42a1e,0.1,false,false,near_mint,en,USD
|
||||
Thornvault Forager,BLB,Bloomburrow,197,normal,rare,1,95807,8c2d6b02-a453-40f9-992a-5c5542987cfb,0.65,false,false,near_mint,en,USD
|
||||
Eddymurk Crab,BLB,Bloomburrow,48,normal,uncommon,1,96132,e6d45abe-4962-47d9-a54e-7e623ea8647c,0.18,false,false,near_mint,en,USD
|
||||
Moonstone Harbinger,BLB,Bloomburrow,101,normal,uncommon,1,95922,59e4aa8d-1d06-48db-b205-aa2f1392bbcb,0.03,false,false,near_mint,en,USD
|
||||
Brazen Collector,BLB,Bloomburrow,128,normal,uncommon,1,95873,78b55a58-c669-4dc6-aa63-5d9dff52e613,0.09,false,false,near_mint,en,USD
|
||||
Brightblade Stoat,BLB,Bloomburrow,4,normal,uncommon,1,95882,df7fea2e-7414-4bc8-adb0-9342e174c009,0.07,false,false,near_mint,en,USD
|
||||
Warren Warleader,BLB,Bloomburrow,38,normal,mythic,1,95849,eb5237a0-5ac3-4ded-9f92-5f782a7bbbd7,3.14,false,false,near_mint,en,USD
|
||||
Kitnap,BLB,Bloomburrow,53,normal,rare,1,95739,085be5d1-fd85-46d1-ad39-a8aa75a06a96,0.14,false,false,near_mint,en,USD
|
||||
Fountainport,BLB,Bloomburrow,253,normal,rare,1,96052,658cfcb7-81b7-48c6-9dd2-1663d06108cf,5.77,false,false,near_mint,en,USD
|
||||
Whiskervale Forerunner,BLB,Bloomburrow,40,normal,rare,1,95927,60a78d59-af31-4af9-95aa-2573fe553925,0.17,false,false,near_mint,en,USD
|
||||
Dreamdew Entrancer,BLB,Bloomburrow,211,normal,rare,1,95755,26bd6b0d-8606-4a37-8be3-a852f1a8e99c,0.28,false,false,near_mint,en,USD
|
||||
Playful Shove,BLB,Bloomburrow,145,normal,uncommon,1,95993,07956edf-34c1-4218-9784-ddbca13e380c,0.1,false,false,near_mint,en,USD
|
||||
Feed the Cycle,BLB,Bloomburrow,94,normal,uncommon,1,96067,7e017ff8-2936-4a1b-bece-00004cfbad06,0.12,false,false,near_mint,en,USD
|
||||
Hoarder's Overflow,BLB,Bloomburrow,141,normal,uncommon,1,96112,c2ed5079-07b4-4575-a2c8-5f0cbff888c3,0.04,false,false,near_mint,en,USD
|
||||
Sunspine Lynx,BLB,Bloomburrow,155,normal,rare,1,95875,8995ceaf-b7e0-423c-8f3e-25212d522502,1.8,false,false,near_mint,en,USD
|
||||
Stormcatch Mentor,BLB,Bloomburrow,234,normal,uncommon,1,95813,99754055-6d67-4fde-aff3-41f6af6ea764,0.21,false,false,near_mint,en,USD
|
||||
For the Common Good,BLB,Bloomburrow,172,normal,rare,1,95912,3ec72a27-b622-47d7-bdf3-970ccaef0d2a,0.87,false,false,near_mint,en,USD
|
||||
Dawn's Truce,BLB,Bloomburrow,295,normal,rare,1,95893,0cce7aec-f9b0-461b-8245-5286b741409d,8.43,false,false,near_mint,en,USD
|
||||
"Clement, the Worrywort",BLB,Bloomburrow,329,normal,rare,1,95835,d1a68d51-cd4e-4ee3-abc7-01435085aa26,0.55,false,false,near_mint,en,USD
|
||||
Tender Wildguide,BLB,Bloomburrow,325,normal,rare,1,95760,2dc164c8-62ca-4d59-ae1c-ef273fde9d10,0.63,false,false,near_mint,en,USD
|
||||
Valley Questcaller,BLB,Bloomburrow,299,normal,rare,1,95839,d9f25130-678d-4338-8eb4-b20d2da5bc74,1.0,false,false,near_mint,en,USD
|
||||
Heirloom Epic,BLB,Bloomburrow,246,normal,uncommon,1,96061,7839ce48-0175-494a-ab89-9bdfb7a50cb1,0.06,false,false,near_mint,en,USD
|
||||
Shrike Force,BLB,Bloomburrow,31,normal,uncommon,1,95763,306fec2c-d8b7-4f4b-8f58-10e3b9f3158f,0.14,false,false,near_mint,en,USD
|
||||
Into the Flood Maw,BLB,Bloomburrow,52,normal,uncommon,1,95919,50b9575a-53d9-4df7-b86c-cda021107d3f,1.48,false,false,near_mint,en,USD
|
||||
Salvation Swan,BLB,Bloomburrow,28,normal,rare,1,95635,b2656160-d319-4530-a6e5-c418596c3f12,0.27,false,false,near_mint,en,USD
|
||||
Hired Claw,BLB,Bloomburrow,140,normal,rare,1,95897,1ae41080-0d67-4719-adb2-49bf2a268b6c,2.43,false,false,near_mint,en,USD
|
||||
Starseer Mentor,BLB,Bloomburrow,233,normal,uncommon,1,95791,6b2f6dc5-9fe8-49c1-b24c-1d99ce1da619,0.05,false,false,near_mint,en,USD
|
||||
Mistbreath Elder,BLB,Bloomburrow,184,normal,rare,1,95975,e5246540-5a84-41d8-9e30-8e7a6c0e84e1,0.37,false,false,near_mint,en,USD
|
||||
Hivespine Wolverine,BLB,Bloomburrow,177,normal,uncommon,1,95943,821970a3-a291-4fe9-bb13-dfc54f9c3caf,0.06,false,false,near_mint,en,USD
|
||||
Patchwork Banner,BLB,Bloomburrow,247,normal,uncommon,1,96097,a8a982c8-bc08-44ba-b3ed-9e4b124615d6,4.68,false,false,near_mint,en,USD
|
||||
"Beza, the Bounding Spring",BLB,Bloomburrow,2,normal,mythic,1,95862,fc310a26-b6a0-4e42-98ab-bdfd7b06cb63,9.56,false,false,near_mint,en,USD
|
||||
Essence Channeler,BLB,Bloomburrow,12,normal,rare,1,96042,5aaf7e4c-4d5d-4acc-a834-e6c4a7629408,1.27,false,false,near_mint,en,USD
|
||||
Valley Questcaller,BLB,Bloomburrow,36,normal,rare,1,95826,ba629ca8-a368-4282-8a61-9bf6a5c217f0,1.12,false,false,near_mint,en,USD
|
||||
Conduct Electricity,BLB,Bloomburrow,130,normal,common,1,95906,2f373dd6-2412-453c-85ba-10230dfe473a,0.02,false,false,near_mint,en,USD
|
||||
Glidedive Duo,BLB,Bloomburrow,96,normal,common,1,96026,4831e7ae-54e3-4bd9-b5af-52dc29f81715,0.02,false,false,near_mint,en,USD
|
||||
Mind Spiral,BLB,Bloomburrow,59,normal,common,1,96068,7e24fe6a-607b-49b8-9fca-cecb1e40de7f,0.01,false,false,near_mint,en,USD
|
||||
Starforged Sword,BLB,Bloomburrow,249,normal,uncommon,1,96110,c23d8e96-b972-4c6c-b0c4-b6627621f048,0.03,false,false,near_mint,en,USD
|
||||
Vinereap Mentor,BLB,Bloomburrow,238,normal,uncommon,1,95902,29b615ba-45c4-42a1-8525-1535f0b55300,0.16,false,false,near_mint,en,USD
|
||||
Mindwhisker,BLB,Bloomburrow,60,normal,uncommon,1,96099,aaa10f34-5bfd-4d87-8f07-58de3b0f5663,0.08,false,false,near_mint,en,USD
|
||||
Persistent Marshstalker,BLB,Bloomburrow,104,normal,uncommon,1,95947,8b900c71-713b-4b7e-b4be-ad9f4aa0c139,0.13,false,false,near_mint,en,USD
|
||||
Portent of Calamity,BLB,Bloomburrow,66,normal,rare,1,96073,8599e2dd-9164-4da3-814f-adccef3b9497,0.14,false,false,near_mint,en,USD
|
||||
Fabled Passage,BLB,Bloomburrow,252,normal,rare,1,96075,8809830f-d8e1-4603-9652-0ad8b00234e9,5.13,false,false,near_mint,en,USD
|
||||
Stormsplitter,BLB,Bloomburrow,154,normal,mythic,1,96040,56f214d3-6b93-40db-a693-55e491c8a283,3.12,false,false,near_mint,en,USD
|
||||
Stargaze,BLB,Bloomburrow,114,normal,uncommon,1,95939,777fc599-8de7-44d2-8fdd-9bddf5948a0c,0.14,false,false,near_mint,en,USD
|
||||
Coruscation Mage,BLB,Bloomburrow,131,normal,uncommon,1,95972,dc2c1de0-6233-469a-be72-a050b97d2c8f,0.32,false,false,near_mint,en,USD
|
||||
Dour Port-Mage,BLB,Bloomburrow,47,normal,rare,1,96049,6402133e-eed1-4a46-9667-8b7a310362c1,2.17,false,false,near_mint,en,USD
|
||||
"Muerra, Trash Tactician",BLB,Bloomburrow,227,normal,rare,1,95821,b40e4658-fd68-46d0-9a89-25570a023d19,0.31,false,false,near_mint,en,USD
|
||||
Stormchaser's Talent,BLB,Bloomburrow,75,normal,rare,1,96092,a36e682d-b43d-4e08-bf5b-70d7e924dbe5,13.62,false,false,near_mint,en,USD
|
||||
Sinister Monolith,BLB,Bloomburrow,113,normal,uncommon,1,96012,2a15e06c-2608-4e7a-a16c-d35417669d86,0.08,false,false,near_mint,en,USD
|
||||
Pawpatch Formation,BLB,Bloomburrow,186,normal,uncommon,1,95963,b82c20ad-0f69-4822-ae76-770832cccdf7,1.83,false,false,near_mint,en,USD
|
||||
Plumecreed Mentor,BLB,Bloomburrow,228,normal,uncommon,1,95819,b1aa988f-547e-449a-9f1a-296c01d68d96,0.03,false,false,near_mint,en,USD
|
||||
"Baylen, the Haymaker",BLB,Bloomburrow,205,normal,rare,1,95889,00e93be2-e06b-4774-8ba5-ccf82a6da1d8,1.04,false,false,near_mint,en,USD
|
||||
Long River's Pull,BLB,Bloomburrow,58,normal,uncommon,1,95900,1c81d0fa-81a1-4f9b-a5fd-5a648fd01dea,0.23,false,false,near_mint,en,USD
|
||||
Bonecache Overseer,BLB,Bloomburrow,85,normal,uncommon,1,95944,82defb87-237f-4b77-9673-5bf00607148f,0.08,false,false,near_mint,en,USD
|
||||
Three Tree Scribe,BLB,Bloomburrow,199,normal,uncommon,1,95977,ea2ca1b3-4c1a-4be5-b321-f57db5ff0528,0.15,false,false,near_mint,en,USD
|
||||
Cruelclaw's Heist,BLB,Bloomburrow,88,normal,rare,1,96121,cab4539a-0157-4cbe-b50f-6e2575df74e9,0.48,false,false,near_mint,en,USD
|
||||
Manifold Mouse,BLB,Bloomburrow,143,normal,rare,1,95881,db3832b5-e83f-4569-bd49-fb7b86fa2d47,3.37,false,false,near_mint,en,USD
|
||||
Iridescent Vinelasher,BLB,Bloomburrow,99,normal,rare,1,95877,b2bc854c-4e72-48e0-a098-e3451d6e511d,1.11,false,false,near_mint,en,USD
|
||||
Daggerfang Duo,BLB,Bloomburrow,89,normal,common,1,96468,cea2bb34-e328-44fb-918a-72208c9457e4,0.03,false,false,near_mint,en,USD
|
||||
Stickytongue Sentinel,BLB,Bloomburrow,193,normal,common,1,96105,b5fa9651-b217-4f93-9c46-9bdb11feedcb,0.03,false,false,near_mint,en,USD
|
||||
Brave-Kin Duo,BLB,Bloomburrow,3,normal,common,1,95824,b8dd4693-424d-4d6e-86cf-24401a23d6b1,0.03,false,false,near_mint,en,USD
|
||||
Driftgloom Coyote,BLB,Bloomburrow,11,normal,uncommon,1,95969,d7ab2de3-3aea-461a-a74f-fb742cf8a198,0.03,false,false,near_mint,en,USD
|
||||
Rockface Village,BLB,Bloomburrow,259,normal,uncommon,1,95629,62799d24-39a6-4e66-8ac3-7cafa99e6e6d,0.48,false,false,near_mint,en,USD
|
||||
Flamecache Gecko,BLB,Bloomburrow,135,normal,uncommon,1,96142,fb8e7c97-8393-41b8-bb0b-3983dcc5e7f4,0.08,false,false,near_mint,en,USD
|
||||
Innkeeper's Talent,BLB,Bloomburrow,180,normal,rare,1,95954,941b0afc-0e8f-45f2-ae7f-07595e164611,19.36,false,false,near_mint,en,USD
|
||||
Repel Calamity,BLB,Bloomburrow,27,foil,uncommon,1,95834,d068192a-6270-4981-819d-4945fa4a2b83,0.08,false,false,near_mint,en,USD
|
||||
Galewind Moose,BLB,Bloomburrow,173,foil,uncommon,1,95871,58706bd8-558a-43b9-9f1e-c1ff0044203b,0.14,false,false,near_mint,en,USD
|
||||
Brave-Kin Duo,BLB,Bloomburrow,3,foil,common,1,95824,b8dd4693-424d-4d6e-86cf-24401a23d6b1,0.06,false,false,near_mint,en,USD
|
||||
Agate Assault,BLB,Bloomburrow,122,foil,common,1,96066,7dd9946b-515e-4e0d-9da2-711e126e9fa6,0.03,false,false,near_mint,en,USD
|
||||
Flamecache Gecko,BLB,Bloomburrow,135,foil,uncommon,1,96142,fb8e7c97-8393-41b8-bb0b-3983dcc5e7f4,0.12,false,false,near_mint,en,USD
|
||||
Rabid Gnaw,BLB,Bloomburrow,147,foil,uncommon,1,96014,2f815bae-820a-49f6-8eed-46f658e7b6ff,0.1,false,false,near_mint,en,USD
|
||||
Pond Prophet,BLB,Bloomburrow,229,foil,common,1,95861,fb959e74-61ea-453d-bb9f-ad0183c0e1b1,0.16,false,false,near_mint,en,USD
|
||||
Star Charter,BLB,Bloomburrow,33,foil,uncommon,1,95894,0e209237-00f7-4bf0-8287-ccde02ce8e8d,0.12,false,false,near_mint,en,USD
|
||||
Kindlespark Duo,BLB,Bloomburrow,142,foil,common,1,96096,a839fba3-1b66-4dd1-bf43-9b015b44fc81,0.07,false,false,near_mint,en,USD
|
||||
Crumb and Get It,BLB,Bloomburrow,8,foil,common,1,96259,3c7b3b25-d4b3-4451-9f5c-6eb369541175,0.04,false,false,near_mint,en,USD
|
||||
Peerless Recycling,BLB,Bloomburrow,188,foil,uncommon,1,95925,5f72466c-505b-4371-9366-0fde525a37e6,0.23,false,false,near_mint,en,USD
|
||||
Nocturnal Hunger,BLB,Bloomburrow,102,foil,common,1,96060,742c0409-9abd-4559-b52e-932cc90c531a,0.02,false,false,near_mint,en,USD
|
||||
Seedpod Squire,BLB,Bloomburrow,232,foil,common,1,95852,f3684577-51ce-490e-9b59-b19c733be466,0.03,false,false,near_mint,en,USD
|
||||
Nettle Guard,BLB,Bloomburrow,23,foil,common,1,95949,8c9c3cc3-2aa2-453e-a17c-2baeeaabe0a9,0.05,false,false,near_mint,en,USD
|
||||
Sazacap's Brew,BLB,Bloomburrow,151,foil,common,1,96330,6d963080-b3ec-467d-82f7-39db6ecd6bbc,0.05,false,false,near_mint,en,USD
|
||||
Waterspout Warden,BLB,Bloomburrow,80,foil,common,1,95909,35898b39-98e2-405b-8f18-0e054bd2c29e,0.04,false,false,near_mint,en,USD
|
||||
Mindwhisker,BLB,Bloomburrow,60,foil,uncommon,1,96099,aaa10f34-5bfd-4d87-8f07-58de3b0f5663,0.12,false,false,near_mint,en,USD
|
||||
Splash Portal,BLB,Bloomburrow,74,foil,uncommon,1,95958,adbaa356-28ba-487f-930a-a957d9960ab0,0.28,false,false,near_mint,en,USD
|
||||
Festival of Embers,BLB,Bloomburrow,134,foil,rare,1,96023,4433ee12-2013-4fdc-979f-ae065f63a527,0.2,false,false,near_mint,en,USD
|
||||
Brightblade Stoat,BLB,Bloomburrow,4,foil,uncommon,1,95882,df7fea2e-7414-4bc8-adb0-9342e174c009,0.11,false,false,near_mint,en,USD
|
||||
Mind Spiral,BLB,Bloomburrow,59,foil,common,1,96068,7e24fe6a-607b-49b8-9fca-cecb1e40de7f,0.04,false,false,near_mint,en,USD
|
||||
Rust-Shield Rampager,BLB,Bloomburrow,190,foil,common,1,96117,c96b01f5-83de-4237-a68d-f946c53e31a6,0.04,false,false,near_mint,en,USD
|
||||
Barkform Harvester,BLB,Bloomburrow,243,foil,common,1,95984,f77049a6-0f22-415b-bc89-20bcb32accf6,0.11,false,false,near_mint,en,USD
|
||||
Wax-Wane Witness,BLB,Bloomburrow,39,foil,common,1,95971,d90ea719-5320-46c6-a347-161853a14776,0.05,false,false,near_mint,en,USD
|
||||
Warren Elder,BLB,Bloomburrow,37,foil,common,1,96030,4bf20069-5a20-4f95-976b-6af2b69f3ad0,0.04,false,false,near_mint,en,USD
|
||||
Stickytongue Sentinel,BLB,Bloomburrow,193,foil,common,1,96105,b5fa9651-b217-4f93-9c46-9bdb11feedcb,0.05,false,false,near_mint,en,USD
|
||||
"Vren, the Relentless",BLB,Bloomburrow,239,foil,rare,1,95930,6506277d-f031-4db5-9d16-bf2389094785,0.71,false,false,near_mint,en,USD
|
||||
Three Tree Scribe,BLB,Bloomburrow,199,foil,uncommon,1,95977,ea2ca1b3-4c1a-4be5-b321-f57db5ff0528,0.2,false,false,near_mint,en,USD
|
||||
Glidedive Duo,BLB,Bloomburrow,96,foil,common,1,96026,4831e7ae-54e3-4bd9-b5af-52dc29f81715,0.03,false,false,near_mint,en,USD
|
||||
Bushy Bodyguard,BLB,Bloomburrow,166,foil,uncommon,1,95997,0de60cf7-fa82-4b6f-9f88-6590fba5c863,0.12,false,false,near_mint,en,USD
|
||||
Conduct Electricity,BLB,Bloomburrow,130,foil,common,1,95906,2f373dd6-2412-453c-85ba-10230dfe473a,0.03,false,false,near_mint,en,USD
|
||||
Daggerfang Duo,BLB,Bloomburrow,89,foil,common,1,96468,cea2bb34-e328-44fb-918a-72208c9457e4,0.07,false,false,near_mint,en,USD
|
||||
Shore Up,BLB,Bloomburrow,69,foil,common,1,96277,4dc3b49e-3674-494c-bdea-4374cefd10f4,0.13,false,false,near_mint,en,USD
|
||||
Hidden Grotto,BLB,Bloomburrow,254,foil,common,1,95918,4ba8f2e7-8357-4862-97dc-1942d066023a,0.17,false,false,near_mint,en,USD
|
||||
Cindering Cutthroat,BLB,Bloomburrow,208,foil,common,1,95820,b2ea10dd-21ea-4622-be27-79d03a802b85,0.01,false,false,near_mint,en,USD
|
||||
"Glarb, Calamity's Augur",BLB,Bloomburrow,215,foil,mythic,1,95864,ffc70b2d-5a3a-49ea-97db-175a62248302,4.3,false,false,near_mint,en,USD
|
||||
Kindlespark Duo,BLB,Bloomburrow,142,normal,common,5,96096,a839fba3-1b66-4dd1-bf43-9b015b44fc81,0.04,false,false,near_mint,en,USD
|
||||
Finch Formation,BLB,Bloomburrow,50,normal,common,2,95899,1c671eab-d1ef-4d79-94eb-8b85f0d18699,0.02,false,false,near_mint,en,USD
|
||||
Builder's Talent,BLB,Bloomburrow,5,normal,uncommon,2,96002,15fa581a-724e-4196-a9a3-ff84c54bdb7d,0.08,false,false,near_mint,en,USD
|
||||
Might of the Meek,BLB,Bloomburrow,144,normal,common,9,95627,509bf254-8a2b-4dfa-9ae5-386321b35e8b,0.09,false,false,near_mint,en,USD
|
||||
Nightwhorl Hermit,BLB,Bloomburrow,62,normal,common,3,95994,0928e04f-2568-41e8-b603-7a25cf5f94d0,0.02,false,false,near_mint,en,USD
|
||||
Fell,BLB,Bloomburrow,95,normal,uncommon,2,95830,c96ac326-de44-470b-a592-a4c2a052c091,0.3,false,false,near_mint,en,USD
|
||||
Sunshower Druid,BLB,Bloomburrow,195,normal,common,6,95630,7740abc5-54e1-478d-966e-0fa64e727995,0.04,false,false,near_mint,en,USD
|
||||
Wandertale Mentor,BLB,Bloomburrow,240,normal,uncommon,2,95808,8c399a55-d02e-41ed-b827-8784b738c118,0.09,false,false,near_mint,en,USD
|
||||
Thought-Stalker Warlock,BLB,Bloomburrow,118,normal,uncommon,2,96018,42e80284-d489-493b-ae92-95b742d07cb3,0.12,false,false,near_mint,en,USD
|
||||
Splash Portal,BLB,Bloomburrow,74,normal,uncommon,2,95958,adbaa356-28ba-487f-930a-a957d9960ab0,0.23,false,false,near_mint,en,USD
|
||||
Alania's Pathmaker,BLB,Bloomburrow,123,normal,common,7,96123,d3871fe6-e26e-4ab4-bd81-7e3c7b8135c1,0.02,false,false,near_mint,en,USD
|
||||
Head of the Homestead,BLB,Bloomburrow,216,normal,common,3,95762,2fc20157-edd3-484d-8864-925c071c0551,0.04,false,false,near_mint,en,USD
|
||||
Hidden Grotto,BLB,Bloomburrow,254,normal,common,4,95918,4ba8f2e7-8357-4862-97dc-1942d066023a,0.08,false,false,near_mint,en,USD
|
||||
Star Charter,BLB,Bloomburrow,33,normal,uncommon,3,95894,0e209237-00f7-4bf0-8287-ccde02ce8e8d,0.04,false,false,near_mint,en,USD
|
||||
War Squeak,BLB,Bloomburrow,160,normal,common,4,95999,105964a7-88b7-4340-aa66-e908189a3638,0.02,false,false,near_mint,en,USD
|
||||
Bellowing Crier,BLB,Bloomburrow,42,normal,common,2,96119,ca2215dd-6300-49cf-b9b2-3a840b786c31,0.04,false,false,near_mint,en,USD
|
||||
Cindering Cutthroat,BLB,Bloomburrow,208,normal,common,4,95820,b2ea10dd-21ea-4622-be27-79d03a802b85,0.02,false,false,near_mint,en,USD
|
||||
Intrepid Rabbit,BLB,Bloomburrow,17,normal,common,7,96276,4d70b99d-c8bf-4a56-8957-cf587fe60b81,0.03,false,false,near_mint,en,USD
|
||||
Carrot Cake,BLB,Bloomburrow,7,normal,common,3,95636,eb03bb4f-8b4b-417e-bfc6-294cd2186b2e,0.06,false,false,near_mint,en,USD
|
||||
Thought Shucker,BLB,Bloomburrow,77,normal,common,7,95916,44b0d83b-cc41-4f82-892c-ef6d3293228a,0.02,false,false,near_mint,en,USD
|
||||
Seasoned Warrenguard,BLB,Bloomburrow,30,normal,uncommon,2,96081,90873995-876f-4e89-8bc7-41a74f4d931f,0.09,false,false,near_mint,en,USD
|
||||
Junkblade Bruiser,BLB,Bloomburrow,220,normal,common,3,95810,918fd89b-5ab7-4ae2-920c-faca5e9da7b9,0.04,false,false,near_mint,en,USD
|
||||
Cache Grab,BLB,Bloomburrow,167,normal,common,2,95842,dfd977dc-a7c3-4d0a-aca7-b25bd154e963,0.08,false,false,near_mint,en,USD
|
||||
Lilypad Village,BLB,Bloomburrow,255,normal,uncommon,2,95631,7e95a7cc-ed77-4ca4-80db-61c0fc68bf50,0.14,false,false,near_mint,en,USD
|
||||
Agate-Blade Assassin,BLB,Bloomburrow,82,normal,common,5,96017,39ebb84a-1c52-4b07-9bd0-b360523b3a5b,0.03,false,false,near_mint,en,USD
|
||||
Repel Calamity,BLB,Bloomburrow,27,normal,uncommon,2,95834,d068192a-6270-4981-819d-4945fa4a2b83,0.07,false,false,near_mint,en,USD
|
||||
Hazel's Nocturne,BLB,Bloomburrow,97,normal,uncommon,2,96009,239363df-4de8-4b64-80fc-a1f4b5c36027,0.07,false,false,near_mint,en,USD
|
||||
Treeguard Duo,BLB,Bloomburrow,200,normal,common,4,96077,89c8456e-c971-42b7-abf3-ff5ae1320abe,0.01,false,false,near_mint,en,USD
|
||||
Calamitous Tide,BLB,Bloomburrow,43,normal,uncommon,2,96003,178bc8b2-ffa0-4549-aead-aacb3db3cf19,0.03,false,false,near_mint,en,USD
|
||||
Splash Lasher,BLB,Bloomburrow,73,normal,uncommon,2,95910,362ee125-35a0-46cd-a201-e6797d12d33a,0.04,false,false,near_mint,en,USD
|
||||
Blooming Blast,BLB,Bloomburrow,126,normal,uncommon,2,95996,0cd92a83-cec3-4085-a929-3f204e3e0140,0.06,false,false,near_mint,en,USD
|
||||
Sugar Coat,BLB,Bloomburrow,76,normal,uncommon,2,95887,fcacbe71-efb0-49e1-b2d0-3ee65ec6cf8b,0.05,false,false,near_mint,en,USD
|
||||
Dazzling Denial,BLB,Bloomburrow,45,normal,common,6,96369,8739f1ac-2e57-4b52-a7ff-cc8df5936aad,0.04,false,false,near_mint,en,USD
|
||||
Nettle Guard,BLB,Bloomburrow,23,normal,common,4,95949,8c9c3cc3-2aa2-453e-a17c-2baeeaabe0a9,0.03,false,false,near_mint,en,USD
|
||||
Raccoon Rallier,BLB,Bloomburrow,148,normal,common,5,96104,b5b5180f-5a1c-4df8-9019-195e65a50ce3,0.04,false,false,near_mint,en,USD
|
||||
High Stride,BLB,Bloomburrow,176,normal,common,8,96153,09c8cf4b-8e65-4a1c-b458-28b5ab56b390,0.04,false,false,near_mint,en,USD
|
||||
Otterball Antics,BLB,Bloomburrow,63,normal,uncommon,2,95913,3ff83ff7-e428-4ccc-8341-f223dab76bd1,0.1,false,false,near_mint,en,USD
|
||||
Frilled Sparkshooter,BLB,Bloomburrow,136,normal,common,7,95934,674bbd6d-e329-42cf-963d-88d1ce8fe51e,0.02,false,false,near_mint,en,USD
|
||||
Moonrise Cleric,BLB,Bloomburrow,226,normal,common,3,95767,35f2a71f-31e8-4b51-9dd4-51a5336b3b86,0.04,false,false,near_mint,en,USD
|
||||
Wax-Wane Witness,BLB,Bloomburrow,39,normal,common,3,95971,d90ea719-5320-46c6-a347-161853a14776,0.02,false,false,near_mint,en,USD
|
||||
Pearl of Wisdom,BLB,Bloomburrow,64,normal,common,7,95625,13cb9575-1138-4f99-8e90-0eaf00bdf4a1,0.01,false,false,near_mint,en,USD
|
||||
Run Away Together,BLB,Bloomburrow,67,normal,common,3,95799,7cb7ec70-a5a4-4188-ba1a-e88b81bdbad0,0.04,false,false,near_mint,en,USD
|
||||
Early Winter,BLB,Bloomburrow,93,normal,common,2,95626,5030e6ac-211d-4145-8c87-998a8351a467,0.05,false,false,near_mint,en,USD
|
||||
Three Tree Rootweaver,BLB,Bloomburrow,198,normal,common,2,96469,d1ab6e14-26e0-4174-b5c6-bc0f5c26b177,0.04,false,false,near_mint,en,USD
|
||||
Mudflat Village,BLB,Bloomburrow,257,normal,uncommon,2,95628,53ec4ad3-9cf0-4f1b-a9db-d63feee594ab,0.24,false,false,near_mint,en,USD
|
||||
Starlit Soothsayer,BLB,Bloomburrow,115,normal,common,6,95895,184c1eca-2991-438f-b5d2-cd2529b9c9b4,0.03,false,false,near_mint,en,USD
|
||||
Hop to It,BLB,Bloomburrow,16,normal,uncommon,2,95851,ee7207f8-5daa-42af-aeea-7a489047110b,0.07,false,false,near_mint,en,USD
|
||||
Psychic Whorl,BLB,Bloomburrow,105,normal,common,5,96127,df900308-8432-4a0a-be21-17482026012b,0.04,false,false,near_mint,en,USD
|
||||
Barkform Harvester,BLB,Bloomburrow,243,normal,common,4,95984,f77049a6-0f22-415b-bc89-20bcb32accf6,0.06,false,false,near_mint,en,USD
|
||||
Daring Waverider,BLB,Bloomburrow,44,normal,uncommon,2,95896,19422406-0c1a-497e-bed1-708bc556491a,0.06,false,false,near_mint,en,USD
|
||||
Plumecreed Escort,BLB,Bloomburrow,65,normal,uncommon,2,95983,f71320ed-2f30-49ce-bcb0-19aebba3f0e8,0.05,false,false,near_mint,en,USD
|
||||
Parting Gust,BLB,Bloomburrow,24,normal,uncommon,2,95744,1086e826-94b8-4398-8a38-d8eacca56a43,0.38,false,false,near_mint,en,USD
|
||||
Veteran Guardmouse,BLB,Bloomburrow,237,normal,common,3,95771,3db43c46-b616-4ef8-80ed-0fab345ab3d0,0.01,false,false,near_mint,en,USD
|
||||
Dire Downdraft,BLB,Bloomburrow,46,normal,common,6,96526,f1931f22-974c-43ad-911e-684bf3f9995d,0.02,false,false,near_mint,en,USD
|
||||
Waterspout Warden,BLB,Bloomburrow,80,normal,common,4,95909,35898b39-98e2-405b-8f18-0e054bd2c29e,0.01,false,false,near_mint,en,USD
|
||||
Lupinflower Village,BLB,Bloomburrow,256,normal,uncommon,2,95634,8ab9d56f-9178-4ec9-a5f6-b934f50d8d9d,0.1,false,false,near_mint,en,USD
|
||||
Heartfire Hero,BLB,Bloomburrow,138,normal,uncommon,2,95870,48ace959-66b2-40c8-9bff-fd7ed9c99a82,2.1,false,false,near_mint,en,USD
|
||||
Peerless Recycling,BLB,Bloomburrow,188,normal,uncommon,2,95925,5f72466c-505b-4371-9366-0fde525a37e6,0.1,false,false,near_mint,en,USD
|
||||
Pond Prophet,BLB,Bloomburrow,229,normal,common,4,95861,fb959e74-61ea-453d-bb9f-ad0183c0e1b1,0.09,false,false,near_mint,en,USD
|
||||
Crumb and Get It,BLB,Bloomburrow,8,normal,common,2,96259,3c7b3b25-d4b3-4451-9f5c-6eb369541175,0.03,false,false,near_mint,en,USD
|
||||
Wildfire Howl,BLB,Bloomburrow,162,normal,uncommon,2,96059,7392d397-9836-4df2-944d-c930c9566811,0.05,false,false,near_mint,en,USD
|
||||
Bark-Knuckle Boxer,BLB,Bloomburrow,164,normal,uncommon,2,95921,582637a9-6aa0-4824-bed7-d5fc91bda35e,0.03,false,false,near_mint,en,USD
|
||||
Ruthless Negotiation,BLB,Bloomburrow,108,normal,uncommon,2,95828,c7f4360c-8d68-4058-b9ec-da9948cb060d,0.1,false,false,near_mint,en,USD
|
||||
Three Tree Mascot,FDN,Foundations,682,normal,common,3,100412,40b8bf3a-1cb5-4ce2-ac25-9410f17130de,0.11,false,false,near_mint,en,USD
|
||||
Tempest Angler,BLB,Bloomburrow,235,normal,common,2,95803,850daae4-f0b7-4604-95e7-ad044ec165c3,0.04,false,false,near_mint,en,USD
|
||||
Starscape Cleric,BLB,Bloomburrow,116,normal,uncommon,2,96037,53a938a7-0154-4350-87cb-00da24ec3824,0.62,false,false,near_mint,en,USD
|
||||
Wick's Patrol,BLB,Bloomburrow,121,normal,uncommon,3,95926,5fa0c53d-fe7b-4b8b-ad81-7967ca318ff7,0.07,false,false,near_mint,en,USD
|
||||
Fireglass Mentor,BLB,Bloomburrow,213,normal,uncommon,2,95823,b78fbaa3-c580-4290-9c28-b74169aab2fc,0.08,false,false,near_mint,en,USD
|
||||
Steampath Charger,BLB,Bloomburrow,153,normal,common,2,95890,03bf1296-e347-4070-8c6f-5c362c2f9364,0.03,false,false,near_mint,en,USD
|
||||
Whiskerquill Scribe,BLB,Bloomburrow,161,normal,common,2,96124,da653996-9bd4-40bd-afb4-48c7e070a269,0.01,false,false,near_mint,en,USD
|
||||
Lilysplash Mentor,BLB,Bloomburrow,222,normal,uncommon,3,95789,64de7b1f-a03e-4407-91f1-e108a2f26735,0.12,false,false,near_mint,en,USD
|
||||
Roughshod Duo,BLB,Bloomburrow,150,normal,common,3,96343,78cdcfb9-a247-4c2d-a098-5b57570f8cd5,0.03,false,false,near_mint,en,USD
|
||||
Bonebind Orator,BLB,Bloomburrow,84,normal,common,3,96535,faf226fa-ca09-4468-8804-87b2a7de2c66,0.02,false,false,near_mint,en,USD
|
||||
Agate Assault,BLB,Bloomburrow,122,normal,common,2,96066,7dd9946b-515e-4e0d-9da2-711e126e9fa6,0.02,false,false,near_mint,en,USD
|
||||
Nocturnal Hunger,BLB,Bloomburrow,102,normal,common,3,96060,742c0409-9abd-4559-b52e-932cc90c531a,0.02,false,false,near_mint,en,USD
|
||||
Jolly Gerbils,BLB,Bloomburrow,19,normal,uncommon,2,96167,0eab51d6-ba17-4a8c-8834-25db363f2b6b,0.04,false,false,near_mint,en,USD
|
||||
Downwind Ambusher,BLB,Bloomburrow,92,normal,uncommon,2,95920,55cfd628-933a-4d3d-b2e5-70bc86960d1c,0.02,false,false,near_mint,en,USD
|
||||
Scales of Shale,BLB,Bloomburrow,110,normal,common,2,95955,9ae14276-dbbd-4257-80e9-accd6c19f5b2,0.02,false,false,near_mint,en,USD
|
||||
Treetop Sentries,BLB,Bloomburrow,201,normal,common,4,95974,e16d4d6e-1fe5-4ff6-9877-8c849a24f5e0,0.03,false,false,near_mint,en,USD
|
||||
Seedpod Squire,BLB,Bloomburrow,232,normal,common,4,95852,f3684577-51ce-490e-9b59-b19c733be466,0.01,false,false,near_mint,en,USD
|
||||
Savor,BLB,Bloomburrow,109,normal,common,4,96178,1397f689-dca1-4d35-864b-92c5606afb9a,0.04,false,false,near_mint,en,USD
|
||||
Polliwallop,BLB,Bloomburrow,189,normal,common,2,95935,6bc4963c-d90b-4588-bdb7-85956e42a623,0.03,false,false,near_mint,en,USD
|
||||
Sonar Strike,BLB,Bloomburrow,32,normal,common,2,96093,a50da179-751f-47a8-a547-8c4a291ed381,0.02,false,false,near_mint,en,USD
|
||||
Uncharted Haven,FDN,Foundations,564,normal,common,3,97170,172cd5b7-98fc-4add-b858-a0b3dfb75c19,0.14,false,false,near_mint,en,USD
|
||||
Teapot Slinger,BLB,Bloomburrow,157,normal,uncommon,2,96015,30506844-349f-4b68-8cc1-d028c1611cc7,0.06,false,false,near_mint,en,USD
|
||||
Harvestrite Host,BLB,Bloomburrow,15,normal,uncommon,2,95915,41762689-0c13-4d45-9d81-ba2afad980f8,0.07,false,false,near_mint,en,USD
|
||||
Spellgyre,BLB,Bloomburrow,72,normal,uncommon,2,96139,f6f6620a-1d40-429d-9a0c-aaeb62adaa71,0.08,false,false,near_mint,en,USD
|
||||
Oakhollow Village,BLB,Bloomburrow,258,normal,uncommon,2,95624,0d49b016-b02b-459f-85e9-c04f6bdcb94e,0.35,false,false,near_mint,en,USD
|
||||
Bumbleflower's Sharepot,BLB,Bloomburrow,244,normal,common,2,95924,5f0affd5-5dcd-4dd1-a694-37a9aedf4084,0.02,false,false,near_mint,en,USD
|
||||
Overprotect,BLB,Bloomburrow,185,normal,uncommon,2,95891,079e979f-b618-4625-989c-e0ea5b61ed8a,0.55,false,false,near_mint,en,USD
|
||||
Heaped Harvest,BLB,Bloomburrow,175,normal,common,3,96255,3b5349db-0e0a-4b15-886e-0db403ef49cb,0.1,false,false,near_mint,en,USD
|
||||
Flowerfoot Swordmaster,BLB,Bloomburrow,14,normal,uncommon,2,95812,97ff118f-9c3c-43a2-8085-980c7fe7d227,0.15,false,false,near_mint,en,USD
|
||||
Banishing Light,BLB,Bloomburrow,1,normal,common,6,96011,25a06f82-ebdb-4dd6-bfe8-958018ce557c,0.04,false,false,near_mint,en,USD
|
||||
Sazacap's Brew,BLB,Bloomburrow,151,normal,common,3,96330,6d963080-b3ec-467d-82f7-39db6ecd6bbc,0.05,false,false,near_mint,en,USD
|
||||
Diresight,BLB,Bloomburrow,91,normal,common,3,95985,fada29c0-5293-40a4-b36d-d073ee99e650,0.1,false,false,near_mint,en,USD
|
||||
Gossip's Talent,BLB,Bloomburrow,51,normal,uncommon,2,95961,b299889a-03d6-4659-b0e1-f0830842e40f,0.18,false,false,near_mint,en,USD
|
||||
Fountainport Bell,BLB,Bloomburrow,245,normal,common,3,96094,a5c94bc0-a49d-451b-8e8d-64d46b8b8603,0.04,false,false,near_mint,en,USD
|
||||
Reptilian Recruiter,BLB,Bloomburrow,149,normal,uncommon,2,96072,81dec453-c9d7-42cb-980a-c82f82bede76,0.02,false,false,near_mint,en,USD
|
||||
Thistledown Players,BLB,Bloomburrow,35,normal,common,2,95960,afa8d83f-8586-4127-8b55-9715e9547488,0.01,false,false,near_mint,en,USD
|
||||
Clifftop Lookout,BLB,Bloomburrow,168,normal,uncommon,2,95931,662d3bcc-65f3-4c69-8ea1-446870a1193d,0.16,false,false,near_mint,en,USD
|
||||
Rust-Shield Rampager,BLB,Bloomburrow,190,normal,common,2,96117,c96b01f5-83de-4237-a68d-f946c53e31a6,0.02,false,false,near_mint,en,USD
|
||||
Consumed by Greed,BLB,Bloomburrow,87,normal,uncommon,2,95884,e50acc41-3517-42db-b1d3-1bdfd7294d84,0.09,false,false,near_mint,en,USD
|
||||
Rabbit Response,BLB,Bloomburrow,26,normal,common,2,96114,c4ded450-346d-4917-917a-b62bc0267509,0.02,false,false,near_mint,en,USD
|
||||
Corpseberry Cultivator,BLB,Bloomburrow,210,normal,common,2,95829,c911a759-ed7b-452b-88a3-663478357610,0.02,false,false,near_mint,en,USD
|
||||
Mind Drill Assailant,BLB,Bloomburrow,225,normal,common,2,95783,507ba708-ca9b-453e-b4c2-23b6650eb5a8,0.05,false,false,near_mint,en,USD
|
||||
Hazardroot Herbalist,BLB,Bloomburrow,174,normal,uncommon,2,96130,e2882982-b3a3-4762-a550-6b82db1038e8,0.04,false,false,near_mint,en,USD
|
||||
Dewdrop Cure,BLB,Bloomburrow,10,normal,uncommon,2,95932,666aefc2-44e0-4c27-88d5-7906f245a71f,0.13,false,false,near_mint,en,USD
|
||||
Valley Rally,BLB,Bloomburrow,159,normal,uncommon,2,95878,b6178258-1ad6-4122-a56f-6eb7d0611e84,0.04,false,false,near_mint,en,USD
|
||||
Blacksmith's Talent,BLB,Bloomburrow,125,normal,uncommon,2,96029,4bb318fa-481d-40a7-978e-f01b49101ae0,0.17,false,false,near_mint,en,USD
|
||||
Pileated Provisioner,BLB,Bloomburrow,25,normal,common,2,96102,ae442cd6-c4df-4aad-9b1d-ccd936c5ec96,0.02,false,false,near_mint,en,USD
|
||||
Short Bow,BLB,Bloomburrow,248,normal,uncommon,2,96281,51d8b72b-fa8f-48d3-bddc-d3ce9b8ba2ea,0.15,false,false,near_mint,en,USD
|
||||
Warren Elder,BLB,Bloomburrow,37,normal,common,2,96030,4bf20069-5a20-4f95-976b-6af2b69f3ad0,0.03,false,false,near_mint,en,USD
|
|
88
app/data/test_data/tdmtest.csv
Normal file
88
app/data/test_data/tdmtest.csv
Normal file
@ -0,0 +1,88 @@
|
||||
Name,Set code,Set name,Collector number,Foil,Rarity,Quantity,ManaBox ID,Scryfall ID,Purchase price,Misprint,Altered,Condition,Language,Purchase price currency
|
||||
Sunpearl Kirin,TDM,Tarkir: Dragonstorm,29,foil,uncommon,1,104470,18292b9c-0f42-4ce2-8b85-35d06cf45a63,1.24,false,false,near_mint,en,USD
|
||||
Humbling Elder,TDM,Tarkir: Dragonstorm,48,foil,common,1,104562,3a84c3f8-0030-4653-880e-b2d19272f5fa,0.06,false,false,near_mint,en,USD
|
||||
Fortress Kin-Guard,TDM,Tarkir: Dragonstorm,12,foil,common,1,104900,b647a018-1d70-43a1-a265-928bcd863689,0.04,false,false,near_mint,en,USD
|
||||
Wild Ride,TDM,Tarkir: Dragonstorm,132,foil,common,1,105154,abc8c6f5-6135-428e-8476-1751f82623f9,0.14,false,false,near_mint,en,USD
|
||||
Jade-Cast Sentinel,TDM,Tarkir: Dragonstorm,243,foil,common,1,104293,516ce5fa-bd00-429b-ba22-b38c7dd9306c,0.07,false,false,near_mint,en,USD
|
||||
Sibsig Appraiser,TDM,Tarkir: Dragonstorm,56,foil,common,1,105135,670c5b96-bac6-449b-a2bd-cb43750d3911,0.04,false,false,near_mint,en,USD
|
||||
Channeled Dragonfire,TDM,Tarkir: Dragonstorm,102,foil,uncommon,1,104499,24204881-690c-4043-8771-20cb93385072,0.07,false,false,near_mint,en,USD
|
||||
Meticulous Artisan,TDM,Tarkir: Dragonstorm,112,foil,common,1,104912,baf4c9dd-0546-41ac-a7ba-0bc312fef31e,0.03,false,false,near_mint,en,USD
|
||||
Kishla Trawlers,TDM,Tarkir: Dragonstorm,50,foil,uncommon,1,104472,190fbc55-e8e9-4077-9532-1de7406baabf,0.08,false,false,near_mint,en,USD
|
||||
Rite of Renewal,TDM,Tarkir: Dragonstorm,153,foil,uncommon,1,104390,f737698a-d934-4851-b238-828959ef4835,0.07,false,false,near_mint,en,USD
|
||||
Twin Bolt,TDM,Tarkir: Dragonstorm,128,foil,common,1,105137,688d8e93-d071-4089-9ef9-565ac4ae9ae0,0.04,false,false,near_mint,en,USD
|
||||
Heritage Reclamation,TDM,Tarkir: Dragonstorm,145,foil,common,1,104636,4f8fee37-a050-4329-8b10-46d150e7a95e,0.33,false,false,near_mint,en,USD
|
||||
Sarkhan's Resolve,TDM,Tarkir: Dragonstorm,158,foil,common,1,104952,cae56fef-b661-4bc5-b9a1-3871ae06e491,0.04,false,false,near_mint,en,USD
|
||||
Delta Bloodflies,TDM,Tarkir: Dragonstorm,77,foil,common,1,104457,119bb72d-aed9-47dc-9285-7bc836cc3776,0.05,false,false,near_mint,en,USD
|
||||
Dirgur Island Dragon // Skimming Strike,TDM,Tarkir: Dragonstorm,40,foil,common,1,104342,b1d21a9a-6b0c-4fbc-a427-81be885d326b,0.16,false,false,near_mint,en,USD
|
||||
Kheru Goldkeeper,TDM,Tarkir: Dragonstorm,199,foil,uncommon,1,104798,8d11183a-57f5-4ddb-8a6e-15fff704b114,0.37,false,false,near_mint,en,USD
|
||||
Rainveil Rejuvenator,TDM,Tarkir: Dragonstorm,152,foil,uncommon,1,105148,9bc5c316-6a41-48ba-864b-da3030dd3e0e,0.12,false,false,near_mint,en,USD
|
||||
Rebellious Strike,TDM,Tarkir: Dragonstorm,20,foil,common,1,104949,c9bafe19-3bd6-4da0-b3e5-e0b89262504c,0.06,false,false,near_mint,en,USD
|
||||
Jeskai Brushmaster,TDM,Tarkir: Dragonstorm,195,foil,uncommon,1,104526,2eb06c36-cf7e-47a9-819e-adfc54284153,0.09,false,false,near_mint,en,USD
|
||||
Highspire Bell-Ringer,TDM,Tarkir: Dragonstorm,47,foil,common,1,105020,e75dccf7-2894-4c4a-b516-3eee73acddd3,0.06,false,false,near_mint,en,USD
|
||||
Sagu Pummeler,TDM,Tarkir: Dragonstorm,156,foil,common,1,105169,def9cb5b-4062-481e-b682-3a30443c2e56,0.03,false,false,near_mint,en,USD
|
||||
Roamer's Routine,TDM,Tarkir: Dragonstorm,154,foil,common,1,104396,fb8c2d5c-ba0c-4d50-8898-5c6574b1e974,0.13,false,false,near_mint,en,USD
|
||||
Monastery Messenger,TDM,Tarkir: Dragonstorm,208,foil,common,1,104443,0c9eeced-6464-41f0-bbea-05b3af4cc005,0.04,false,false,near_mint,en,USD
|
||||
Equilibrium Adept,TDM,Tarkir: Dragonstorm,106,foil,uncommon,1,104335,a4ba6d74-c6be-4a5e-8859-b791bb6b8f51,0.07,false,false,near_mint,en,USD
|
||||
Dispelling Exhale,TDM,Tarkir: Dragonstorm,41,foil,common,1,104477,1c9af3f1-711e-42ae-803a-1100eba3fb13,0.21,false,false,near_mint,en,USD
|
||||
Nightblade Brigade,TDM,Tarkir: Dragonstorm,85,foil,common,1,105134,648debd9-d4cf-4788-8882-f1601a3d87f5,0.08,false,false,near_mint,en,USD
|
||||
Tranquil Cove,TDM,Tarkir: Dragonstorm,270,foil,common,1,104249,1c4efa6c-4f29-41cd-a728-bf0e479ace05,0.06,false,false,near_mint,en,USD
|
||||
Rugged Highlands,TDM,Tarkir: Dragonstorm,265,foil,common,1,104267,31261eca-28ad-407c-84ef-0c124d0d7451,0.07,false,false,near_mint,en,USD
|
||||
Swiftwater Cliffs,TDM,Tarkir: Dragonstorm,268,foil,common,1,104361,ca53fb19-b8ca-485b-af1a-5117ae54bfe3,0.11,false,false,near_mint,en,USD
|
||||
Dismal Backwater,TDM,Tarkir: Dragonstorm,254,foil,common,1,104238,082b52c9-c46e-44d3-b723-546ba528e07b,0.06,false,false,near_mint,en,USD
|
||||
Wind-Scarred Crag,TDM,Tarkir: Dragonstorm,271,foil,common,1,104286,4912e4d0-b16a-4aa6-a583-3430d26bd591,0.05,false,false,near_mint,en,USD
|
||||
Jungle Hollow,TDM,Tarkir: Dragonstorm,258,foil,common,1,104375,ea13440b-3f7b-4182-9541-27c1fa3121e5,0.07,false,false,near_mint,en,USD
|
||||
Plains,TDM,Tarkir: Dragonstorm,272,foil,common,1,104240,0d0f1dd6-9564-4adc-af7d-f83252e8581a,0.39,false,false,near_mint,en,USD
|
||||
Purging Stormbrood // Absorb Essence,TDM,Tarkir: Dragonstorm,315,foil,uncommon,1,104395,fb293f4f-9ba2-48f5-a4fb-d902aa531bfc,0.18,false,false,near_mint,en,USD
|
||||
Barrensteppe Siege,TDM,Tarkir: Dragonstorm,384,foil,rare,1,104002,c09d4015-f101-4529-a603-c66192dcfd92,1.68,false,false,near_mint,en,USD
|
||||
United Battlefront,TDM,Tarkir: Dragonstorm,32,foil,rare,1,104370,dff398be-4ba4-4976-9acc-be99d2e07a61,0.8,false,false,near_mint,en,USD
|
||||
Cori-Steel Cutter,TDM,Tarkir: Dragonstorm,103,foil,rare,1,104608,490eb213-9ae2-4b45-abec-6f1dfc83792a,15.2,false,false,near_mint,en,USD
|
||||
Plains,TDM,Tarkir: Dragonstorm,272,normal,common,1,104240,0d0f1dd6-9564-4adc-af7d-f83252e8581a,0.52,false,false,near_mint,en,USD
|
||||
Skirmish Rhino,TDM,Tarkir: Dragonstorm,224,normal,uncommon,1,103992,4a2e9ba1-c254-41e3-9845-4e81f9fec38d,0.18,false,false,near_mint,en,USD
|
||||
Kheru Goldkeeper,TDM,Tarkir: Dragonstorm,199,normal,uncommon,1,104798,8d11183a-57f5-4ddb-8a6e-15fff704b114,0.17,false,false,near_mint,en,USD
|
||||
Hardened Tactician,TDM,Tarkir: Dragonstorm,191,normal,uncommon,1,104780,86b225cb-5c45-4da1-a64e-b04091e483e8,0.19,false,false,near_mint,en,USD
|
||||
Auroral Procession,TDM,Tarkir: Dragonstorm,169,normal,uncommon,1,104701,672f94ad-65d6-4c7d-925d-165ef264626f,0.2,false,false,near_mint,en,USD
|
||||
Barrensteppe Siege,TDM,Tarkir: Dragonstorm,171,normal,rare,1,103989,2556a35b-2229-42c7-8cb3-c8c668403dd2,0.46,false,false,near_mint,en,USD
|
||||
Heritage Reclamation,TDM,Tarkir: Dragonstorm,145,normal,common,1,104636,4f8fee37-a050-4329-8b10-46d150e7a95e,0.19,false,false,near_mint,en,USD
|
||||
Windcrag Siege,TDM,Tarkir: Dragonstorm,235,normal,rare,1,104534,31a8329b-23a1-4c49-a579-a5da8d01435a,1.97,false,false,near_mint,en,USD
|
||||
Ambling Stormshell,TDM,Tarkir: Dragonstorm,37,normal,rare,1,104942,c74d4a57-0f66-4965-9ed7-f88a08aa1d15,0.18,false,false,near_mint,en,USD
|
||||
"Narset, Jeskai Waymaster",TDM,Tarkir: Dragonstorm,209,normal,rare,1,103995,6b77cbc1-dbc8-44d9-aa29-15cbb19afecd,0.21,false,false,near_mint,en,USD
|
||||
Revival of the Ancestors,TDM,Tarkir: Dragonstorm,218,normal,rare,1,105074,fd742ff5-f0ea-4f4b-911e-4c09e2154dba,0.14,false,false,near_mint,en,USD
|
||||
Dragonback Assault,TDM,Tarkir: Dragonstorm,179,normal,mythic,1,104985,d54cc838-d79d-433a-99fb-d6e4d1c1431d,3.49,false,false,near_mint,en,USD
|
||||
Call the Spirit Dragons,TDM,Tarkir: Dragonstorm,174,normal,mythic,1,104888,b1ad91db-5f16-4392-baf1-f8400ec11e0a,3.99,false,false,near_mint,en,USD
|
||||
Flamehold Grappler,TDM,Tarkir: Dragonstorm,185,normal,rare,1,104958,cc8443a6-282f-4218-9dc8-144b5570d891,0.26,false,false,near_mint,en,USD
|
||||
New Way Forward,TDM,Tarkir: Dragonstorm,211,normal,rare,1,104996,d9d48f9e-79f0-478c-9db0-ff7ac4a8f401,0.2,false,false,near_mint,en,USD
|
||||
Eshki Dragonclaw,TDM,Tarkir: Dragonstorm,182,normal,rare,1,104445,0d369c44-78ee-4f3c-bf2b-cddba7fe26d4,0.22,false,false,near_mint,en,USD
|
||||
Warden of the Grove,TDM,Tarkir: Dragonstorm,166,normal,rare,1,104498,2414db96-0e2b-4f7c-9b97-41f8e310b752,0.94,false,false,near_mint,en,USD
|
||||
United Battlefront,TDM,Tarkir: Dragonstorm,32,normal,rare,1,104370,dff398be-4ba4-4976-9acc-be99d2e07a61,0.58,false,false,near_mint,en,USD
|
||||
Dalkovan Encampment,TDM,Tarkir: Dragonstorm,253,normal,rare,1,104822,98ad5f0c-8775-4e89-8e92-84a6ade93e35,0.3,false,false,near_mint,en,USD
|
||||
Marang River Regent // Coil and Catch,TDM,Tarkir: Dragonstorm,51,normal,rare,1,104393,f890bdc7-32e6-4492-bac7-7cabf54a8bfd,2.87,false,false,near_mint,en,USD
|
||||
Sage of the Skies,TDM,Tarkir: Dragonstorm,22,normal,rare,1,104710,6ade6918-6d1d-448d-ab56-93996051e9a9,0.28,false,false,near_mint,en,USD
|
||||
Tersa Lightshatter,TDM,Tarkir: Dragonstorm,127,normal,rare,1,104825,99e96b34-b1c4-4647-a38e-2cf1aedaaace,1.92,false,false,near_mint,en,USD
|
||||
Voice of Victory,TDM,Tarkir: Dragonstorm,33,normal,rare,1,104377,ec3de5f4-bb55-4ab9-995f-f3e0dc22c1bb,10.45,false,false,near_mint,en,USD
|
||||
Yathan Roadwatcher,TDM,Tarkir: Dragonstorm,236,normal,rare,1,104800,8e77339b-dd82-481c-9ee2-4156ca69ad35,0.15,false,false,near_mint,en,USD
|
||||
Great Arashin City,TDM,Tarkir: Dragonstorm,257,normal,rare,1,105033,ecba23b6-9f3a-431e-bc22-f1fb04d27b68,0.31,false,false,near_mint,en,USD
|
||||
"Taigam, Master Opportunist",TDM,Tarkir: Dragonstorm,60,normal,mythic,1,104320,8693d631-05f6-414d-9e49-6385746e8960,1.69,false,false,near_mint,en,USD
|
||||
Temur Battlecrier,TDM,Tarkir: Dragonstorm,228,normal,rare,1,104309,72184791-0767-4108-920c-763e92dae2d4,0.74,false,false,near_mint,en,USD
|
||||
Fangkeeper's Familiar,TDM,Tarkir: Dragonstorm,183,normal,rare,1,104696,655fa2e1-3e1c-424c-b17a-daa7b8fface4,0.27,false,false,near_mint,en,USD
|
||||
Nature's Rhythm,TDM,Tarkir: Dragonstorm,150,normal,rare,1,104460,1397d904-c51d-451e-8505-7f3118acc1f6,3.21,false,false,near_mint,en,USD
|
||||
Stillness in Motion,TDM,Tarkir: Dragonstorm,59,normal,rare,1,104864,a6289251-17e4-4987-96b9-2fb1a8f90e2a,0.18,false,false,near_mint,en,USD
|
||||
Stadium Headliner,TDM,Tarkir: Dragonstorm,122,normal,rare,1,104552,37d4ab2a-a06a-4768-b5e1-e1def957d7f4,0.46,false,false,near_mint,en,USD
|
||||
Sinkhole Surveyor,TDM,Tarkir: Dragonstorm,93,normal,rare,1,104551,37cb5599-7d2c-48e9-978b-902a01a74bde,0.23,false,false,near_mint,en,USD
|
||||
Naga Fleshcrafter,TDM,Tarkir: Dragonstorm,52,normal,rare,1,104675,5df17423-9fdd-4432-8660-1d267c685595,0.31,false,false,near_mint,en,USD
|
||||
Herd Heirloom,TDM,Tarkir: Dragonstorm,144,normal,rare,1,104873,a88c7713-b3a9-4685-b1d3-623d35b62365,4.48,false,false,near_mint,en,USD
|
||||
Severance Priest,TDM,Tarkir: Dragonstorm,222,normal,rare,1,104917,bc779a1b-128c-4c74-bebd-bdb687867f68,0.21,false,false,near_mint,en,USD
|
||||
Dracogenesis,TDM,Tarkir: Dragonstorm,105,normal,mythic,1,104241,0d5674f9-22b2-45f9-902d-4fd245485c60,15.05,false,false,near_mint,en,USD
|
||||
Kishla Village,TDM,Tarkir: Dragonstorm,259,normal,rare,1,104840,9f0ff90d-7312-44df-afc5-29c768fa7758,0.3,false,false,near_mint,en,USD
|
||||
"Sarkhan, Dragon Ascendant",TDM,Tarkir: Dragonstorm,302,normal,rare,1,103994,57c03255-e3dc-44c2-982b-7efa188280df,0.49,false,false,near_mint,en,USD
|
||||
Bloomvine Regent // Claim Territory,TDM,Tarkir: Dragonstorm,381,normal,rare,1,104237,081f2de5-251a-41c9-a62f-11487f54d355,1.97,false,false,near_mint,en,USD
|
||||
Kheru Goldkeeper,TDM,Tarkir: Dragonstorm,313,normal,uncommon,1,105150,9d85ba44-8f29-4c49-b77f-8a6692d23c8c,0.45,false,false,near_mint,en,USD
|
||||
Boulderborn Dragon,TDM,Tarkir: Dragonstorm,323,normal,common,1,104326,970e11f0-337a-46b5-9bff-4bcb7843ed3a,0.1,false,false,near_mint,en,USD
|
||||
Sagu Wildling // Roost Seek,TDM,Tarkir: Dragonstorm,306,normal,common,1,104903,b72ee8f9-5e79-4f77-ae7e-e4c274f78187,0.11,false,false,near_mint,en,USD
|
||||
Tempest Hawk,TDM,Tarkir: Dragonstorm,31,normal,common,1,104587,422f9453-ab12-4e3c-8c51-be87391395a1,0.66,false,false,near_mint,en,USD
|
||||
Eshki Dragonclaw,TDM,Tarkir: Dragonstorm,356,normal,rare,1,104877,aafaa59e-87e1-4953-8c04-8e7a3a509827,0.44,false,false,near_mint,en,USD
|
||||
Lotuslight Dancers,TDM,Tarkir: Dragonstorm,363,normal,rare,1,104751,79dc69dc-6245-43fc-95a2-85b2c2957182,0.32,false,false,near_mint,en,USD
|
||||
Rakshasa's Bargain,TDM,Tarkir: Dragonstorm,214,normal,uncommon,1,104299,5c409f4f-3b2c-4c33-b850-55b2a46f51ca,0.32,false,false,near_mint,en,USD
|
||||
Glacial Dragonhunt,TDM,Tarkir: Dragonstorm,188,normal,uncommon,1,104814,95994c88-e404-4a4f-8be6-b99d703d4609,0.1,false,false,near_mint,en,USD
|
||||
"Elspeth, Storm Slayer",TDM,Tarkir: Dragonstorm,11,normal,mythic,1,104311,73a065e3-b530-4e62-ab3c-4f6f908184ec,39.69,false,false,near_mint,en,USD
|
||||
Host of the Hereafter,TDM,Tarkir: Dragonstorm,193,normal,uncommon,2,104448,0f182957-8133-45a7-80a3-1944bead4d43,0.16,false,false,near_mint,en,USD
|
||||
Sunset Strikemaster,TDM,Tarkir: Dragonstorm,126,normal,uncommon,2,104394,f8f1a2f2-526d-4b2c-985b-0acfdc21a2ee,0.17,false,false,near_mint,en,USD
|
|
129
app/main.py
129
app/main.py
@ -1,4 +1,4 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
@ -6,15 +6,18 @@ from contextlib import asynccontextmanager
|
||||
import uvicorn
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from app.routes import routes
|
||||
from app.db.database import init_db, SessionLocal
|
||||
from app.services.service_manager import ServiceManager
|
||||
import logging
|
||||
from app.models.tcgplayer_products import MostRecentTCGPlayerPrice
|
||||
|
||||
# Configure logging
|
||||
log_file = "app.log"
|
||||
if os.path.exists(log_file):
|
||||
os.remove(log_file) # Remove existing log file to start fresh
|
||||
log_file = Path("app.log")
|
||||
if log_file.exists():
|
||||
# Archive old log file instead of deleting
|
||||
archive_path = log_file.with_suffix(f'.{log_file.stat().st_mtime}.log')
|
||||
log_file.rename(archive_path)
|
||||
|
||||
# Create a formatter
|
||||
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(name)s - %(message)s')
|
||||
@ -28,7 +31,7 @@ file_handler.setFormatter(formatter)
|
||||
|
||||
# Configure root logger
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(logging.INFO)
|
||||
root_logger.setLevel(logging.DEBUG)
|
||||
root_logger.addHandler(console_handler)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
@ -37,27 +40,54 @@ logger = logging.getLogger(__name__)
|
||||
logger.info("Application starting up...")
|
||||
|
||||
# Initialize service manager
|
||||
service_manager = ServiceManager()
|
||||
service_manager = None
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global service_manager
|
||||
service_manager = ServiceManager()
|
||||
|
||||
# Startup
|
||||
init_db()
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
# Initialize all services
|
||||
await service_manager.initialize_services()
|
||||
|
||||
# Start the scheduler
|
||||
scheduler = service_manager.get_service('scheduler')
|
||||
await scheduler.start_scheduled_tasks()
|
||||
logger.info("Scheduler started successfully")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
await service_manager.cleanup_services()
|
||||
logger.info("All services cleaned up successfully")
|
||||
try:
|
||||
init_db()
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
# Initialize all services
|
||||
await service_manager.initialize_services()
|
||||
|
||||
# Get a database session
|
||||
db = SessionLocal()
|
||||
try:
|
||||
#data_init_service = service_manager.get_service('data_initialization')
|
||||
#data_init = await data_init_service.initialize_data(db, game_ids=[1], use_cache=False, init_categories=True, init_products=True, init_groups=True, init_archived_prices=True, init_mtgjson=False, archived_prices_start_date="2025-06-07", archived_prices_end_date="2025-06-08")
|
||||
#logger.info(f"Data initialization results: {data_init}")
|
||||
# Update most recent prices
|
||||
#MostRecentTCGPlayerPrice.update_most_recent_prices(db)
|
||||
#logger.info("Most recent prices updated successfully")
|
||||
|
||||
# Create default customer, vendor, and marketplace
|
||||
#inv_data_init = await data_init_service.initialize_inventory_data(db)
|
||||
#logger.info(f"Inventory data initialization results: {inv_data_init}")
|
||||
# Start the scheduler
|
||||
scheduler = service_manager.get_service('scheduler')
|
||||
#await scheduler.refresh_tcgplayer_inventory_table(db)
|
||||
await scheduler.start_scheduled_tasks(db)
|
||||
logger.info("Scheduler started successfully")
|
||||
|
||||
yield
|
||||
except Exception as e:
|
||||
logger.error(f"Error during application startup: {str(e)}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Critical error during application startup: {str(e)}")
|
||||
raise
|
||||
finally:
|
||||
# Shutdown
|
||||
if service_manager:
|
||||
await service_manager.cleanup_services()
|
||||
logger.info("All services cleaned up successfully")
|
||||
|
||||
app = FastAPI(
|
||||
title="CCR Cards Management API",
|
||||
@ -72,16 +102,63 @@ app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
# Serve index.html at root
|
||||
@app.get("/")
|
||||
async def read_root():
|
||||
return FileResponse('app/static/index.html')
|
||||
index_path = Path('app/static/index.html')
|
||||
if not index_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Index file not found")
|
||||
return FileResponse(index_path)
|
||||
|
||||
# Serve app.js
|
||||
@app.get("/app.js")
|
||||
async def read_app_js():
|
||||
return FileResponse('app/static/app.js')
|
||||
js_path = Path('app/static/app.js')
|
||||
if not js_path.exists():
|
||||
raise HTTPException(status_code=404, detail="App.js file not found")
|
||||
return FileResponse(js_path)
|
||||
|
||||
# Serve manabox.html
|
||||
@app.get("/manabox.html")
|
||||
async def read_manabox_html():
|
||||
html_path = Path('app/static/manabox.html')
|
||||
if not html_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Manabox.html file not found")
|
||||
return FileResponse(html_path)
|
||||
|
||||
# Serve manabox.js
|
||||
@app.get("/manabox.js")
|
||||
async def read_manabox_js():
|
||||
js_path = Path('app/static/manabox.js')
|
||||
if not js_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Manabox.js file not found")
|
||||
return FileResponse(js_path)
|
||||
|
||||
# serve transactions.html
|
||||
@app.get("/transactions.html")
|
||||
async def read_transactions_html():
|
||||
html_path = Path('app/static/transactions.html')
|
||||
if not html_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Transaction.html file not found")
|
||||
return FileResponse(html_path)
|
||||
|
||||
# serve transactions.js
|
||||
@app.get("/transactions.js")
|
||||
async def read_transactions_js():
|
||||
js_path = Path('app/static/transactions.js')
|
||||
if not js_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Transaction.js file not found")
|
||||
return FileResponse(js_path)
|
||||
|
||||
# serve styles.css
|
||||
@app.get("/styles.css")
|
||||
async def read_styles_css():
|
||||
css_path = Path('app/static/styles.css')
|
||||
if not css_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Styles.css file not found")
|
||||
return FileResponse(css_path)
|
||||
|
||||
# Configure CORS with specific origins in production
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=["http://localhost:3000", "http://192.168.1.124:3000"], # Update with your frontend URL
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
|
@ -1,26 +1,62 @@
|
||||
from app.models.box import Box
|
||||
from app.models.card import Card
|
||||
from app.models.critical_error_log import CriticalErrorLog
|
||||
from app.models.file import File
|
||||
from app.models.game import Game
|
||||
from app.models.inventory import Inventory
|
||||
from app.models.mtgjson_card import MTGJSONCard
|
||||
from app.models.mtgjson_sku import MTGJSONSKU
|
||||
from app.models.tcgplayer_category import TCGPlayerCategory
|
||||
from app.models.tcgplayer_group import TCGPlayerGroup
|
||||
from app.models.tcgplayer_order import TCGPlayerOrder
|
||||
from app.models.tcgplayer_product import TCGPlayerProduct
|
||||
from app.models.inventory_management import (
|
||||
PhysicalItem,
|
||||
InventoryItem,
|
||||
TransactionItem,
|
||||
OpenEvent,
|
||||
Vendor,
|
||||
Customer,
|
||||
Transaction,
|
||||
SealedExpectedValue,
|
||||
Marketplace,
|
||||
MarketplaceListing
|
||||
)
|
||||
from app.models.tcgplayer_products import (
|
||||
MTGJSONCard,
|
||||
MTGJSONSKU,
|
||||
TCGPlayerProduct,
|
||||
TCGPlayerCategory,
|
||||
TCGPlayerGroup,
|
||||
TCGPlayerPriceHistory,
|
||||
MostRecentTCGPlayerPrice
|
||||
)
|
||||
from app.models.tcgplayer_order import (
|
||||
TCGPlayerOrder,
|
||||
TCGPlayerOrderTransaction,
|
||||
TCGPlayerOrderProduct,
|
||||
TCGPlayerOrderRefund
|
||||
)
|
||||
from app.models.tcgplayer_inventory import TCGPlayerInventory
|
||||
from app.models.manabox_import_staging import ManaboxImportStaging
|
||||
from app.models.pricing import PricingEvent
|
||||
|
||||
|
||||
# This makes all models available for Alembic to discover
|
||||
__all__ = [
|
||||
'Box',
|
||||
'Card',
|
||||
'CriticalErrorLog',
|
||||
'File',
|
||||
'Game',
|
||||
'Inventory',
|
||||
'PhysicalItem',
|
||||
'InventoryItem',
|
||||
'TransactionItem',
|
||||
'OpenEvent',
|
||||
'Vendor',
|
||||
'Customer',
|
||||
'Transaction',
|
||||
'SealedExpectedValue',
|
||||
'Marketplace',
|
||||
'MarketplaceListing',
|
||||
'MTGJSONCard',
|
||||
'MTGJSONSKU',
|
||||
'TCGPlayerProduct',
|
||||
'TCGPlayerCategory',
|
||||
'TCGPlayerGroup',
|
||||
'TCGPlayerInventory',
|
||||
'ManaboxImportStaging',
|
||||
'TCGPlayerOrder',
|
||||
'TCGPlayerProduct'
|
||||
'TCGPlayerOrderTransaction',
|
||||
'TCGPlayerOrderProduct',
|
||||
'TCGPlayerOrderRefund',
|
||||
'TCGPlayerPriceHistory',
|
||||
'MostRecentTCGPlayerPrice',
|
||||
'PricingEvent'
|
||||
]
|
@ -1,30 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.database import Base
|
||||
|
||||
class Box(Base):
|
||||
__tablename__ = "boxes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer)
|
||||
type = Column(String)
|
||||
set_code = Column(String)
|
||||
sku = Column(Integer)
|
||||
name = Column(String)
|
||||
game_id = Column(Integer, ForeignKey("games.id"))
|
||||
expected_number_of_cards = Column(Integer)
|
||||
description = Column(String)
|
||||
image_url = Column(String)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
class OpenBox(Base):
|
||||
__tablename__ = "open_boxes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
box_id = Column(Integer, ForeignKey("boxes.id"))
|
||||
number_of_cards = Column(Integer)
|
||||
date_opened = Column(DateTime(timezone=True))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
@ -1,37 +0,0 @@
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Float, ForeignKey, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
class Card(Base):
|
||||
__tablename__ = "cards"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, index=True)
|
||||
rarity = Column(String)
|
||||
set_name = Column(String, index=True)
|
||||
price = Column(Float)
|
||||
quantity = Column(Integer, default=0)
|
||||
|
||||
# TCGPlayer specific fields
|
||||
tcgplayer_sku = Column(String, unique=True, index=True)
|
||||
product_line = Column(String)
|
||||
product_name = Column(String)
|
||||
title = Column(String)
|
||||
number = Column(String)
|
||||
condition = Column(String)
|
||||
tcg_market_price = Column(Float)
|
||||
tcg_direct_low = Column(Float)
|
||||
tcg_low_price_with_shipping = Column(Float)
|
||||
tcg_low_price = Column(Float)
|
||||
total_quantity = Column(Integer)
|
||||
add_to_quantity = Column(Integer)
|
||||
tcg_marketplace_price = Column(Float)
|
||||
photo_url = Column(String)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
@ -1,14 +1,11 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.database import Base
|
||||
|
||||
class Game(Base):
|
||||
__tablename__ = "games"
|
||||
class CriticalErrorLog(Base):
|
||||
__tablename__ = "critical_error_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String)
|
||||
description = Column(String)
|
||||
image_url = Column(String)
|
||||
error_message = Column(String, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
@ -1,9 +1,6 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
@ -15,7 +12,11 @@ class File(Base):
|
||||
file_type = Column(String)
|
||||
content_type = Column(String)
|
||||
path = Column(String)
|
||||
size = Column(Integer) # File size in bytes
|
||||
size = Column(Integer)
|
||||
file_metadata = Column(JSON)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
manabox_import_staging = relationship("ManaboxImportStaging", back_populates="file")
|
@ -1,28 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.database import Base
|
||||
|
||||
class Inventory(Base):
|
||||
__tablename__ = "inventory"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tcgplayer_id = Column(String, unique=True, index=True)
|
||||
product_line = Column(String)
|
||||
set_name = Column(String)
|
||||
product_name = Column(String)
|
||||
title = Column(String)
|
||||
number = Column(String)
|
||||
rarity = Column(String)
|
||||
condition = Column(String)
|
||||
tcg_market_price = Column(Float)
|
||||
tcg_direct_low = Column(Float)
|
||||
tcg_low_price_with_shipping = Column(Float)
|
||||
tcg_low_price = Column(Float)
|
||||
total_quantity = Column(Integer)
|
||||
add_to_quantity = Column(Integer)
|
||||
tcg_marketplace_price = Column(Float)
|
||||
photo_url = Column(String)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
321
app/models/inventory_management.py
Normal file
321
app/models/inventory_management.py
Normal file
@ -0,0 +1,321 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, CheckConstraint, Index, Boolean, Table, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.db.database import Base
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from datetime import datetime
|
||||
from app.models.critical_error_log import CriticalErrorLog
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
open_event_resulting_items = Table(
|
||||
"open_event_resulting_items",
|
||||
Base.metadata,
|
||||
Column("event_id", Integer, ForeignKey("open_events.id"), primary_key=True),
|
||||
Column("item_id", Integer, ForeignKey("physical_items.id"), primary_key=True)
|
||||
)
|
||||
|
||||
class PhysicalItem(Base):
|
||||
__tablename__ = "physical_items"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
item_type = Column(String)
|
||||
|
||||
# at least one of these must be set to pass the constraint
|
||||
tcgplayer_product_id = Column(Integer, nullable=True)
|
||||
tcgplayer_sku_id = Column(Integer, ForeignKey("mtgjson_skus.tcgplayer_sku_id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"(tcgplayer_sku_id IS NOT NULL OR tcgplayer_product_id IS NOT NULL)",
|
||||
name="ck_physical_items_sku_or_product_not_null"
|
||||
),
|
||||
)
|
||||
|
||||
__mapper_args__ = {
|
||||
'polymorphic_on': item_type,
|
||||
'polymorphic_identity': 'physical_item'
|
||||
}
|
||||
|
||||
# Relationships
|
||||
sku = relationship("MTGJSONSKU", back_populates="physical_items", primaryjoin="PhysicalItem.tcgplayer_sku_id == MTGJSONSKU.tcgplayer_sku_id")
|
||||
product_direct = relationship("TCGPlayerProduct",
|
||||
back_populates="physical_items_direct",
|
||||
primaryjoin="PhysicalItem.tcgplayer_product_id == foreign(TCGPlayerProduct.tcgplayer_product_id)", uselist=False)
|
||||
inventory_item = relationship("InventoryItem", uselist=False, back_populates="physical_item")
|
||||
#transaction_items = relationship("TransactionItem", back_populates="physical_item")
|
||||
source_open_events = relationship(
|
||||
"OpenEvent",
|
||||
back_populates="source_item",
|
||||
foreign_keys="[OpenEvent.source_item_id]"
|
||||
)
|
||||
resulting_open_events = relationship(
|
||||
"OpenEvent",
|
||||
secondary=open_event_resulting_items,
|
||||
back_populates="resulting_items"
|
||||
)
|
||||
|
||||
@hybrid_property
|
||||
def is_sealed(self):
|
||||
return not self.source_open_events
|
||||
|
||||
@hybrid_property
|
||||
def products(self):
|
||||
if self.sku and self.sku.product:
|
||||
return self.sku.product
|
||||
elif self.product_direct:
|
||||
return self.product_direct
|
||||
else:
|
||||
return None
|
||||
|
||||
class InventoryItem(Base):
|
||||
__tablename__ = "inventory_items"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
physical_item_id = Column(Integer, ForeignKey("physical_items.id"), unique=True)
|
||||
cost_basis = Column(Float)
|
||||
parent_id = Column(Integer, ForeignKey("inventory_items.id"), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
physical_item = relationship("PhysicalItem", back_populates="inventory_item")
|
||||
parent = relationship("InventoryItem", remote_side=[id], back_populates="children")
|
||||
children = relationship("InventoryItem", back_populates="parent", overlaps="parent")
|
||||
marketplace_listing = relationship("MarketplaceListing", back_populates="inventory_item")
|
||||
transaction_items = relationship("TransactionItem", back_populates="inventory_item")
|
||||
|
||||
@property
|
||||
def products(self):
|
||||
"""
|
||||
Proxy access to the associated TCGPlayerProduct(s) via the linked PhysicalItem.
|
||||
Returns:
|
||||
list[TCGPlayerProduct] or [] if no physical item or no linked products.
|
||||
"""
|
||||
return self.physical_item.products if self.physical_item else None
|
||||
|
||||
def soft_delete(self, timestamp=None):
|
||||
if not timestamp:
|
||||
timestamp = datetime.now()
|
||||
self.deleted_at = timestamp
|
||||
for child in self.children:
|
||||
child.soft_delete(timestamp)
|
||||
|
||||
class Box(PhysicalItem):
|
||||
__tablename__ = "boxes"
|
||||
|
||||
id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True)
|
||||
expected_value = Column(Float)
|
||||
|
||||
__mapper_args__ = {
|
||||
'polymorphic_identity': 'box'
|
||||
}
|
||||
|
||||
class Case(PhysicalItem):
|
||||
__tablename__ = "cases"
|
||||
|
||||
id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True)
|
||||
expected_value = Column(Float)
|
||||
num_boxes = Column(Integer)
|
||||
|
||||
__mapper_args__ = {
|
||||
'polymorphic_identity': 'case'
|
||||
}
|
||||
|
||||
class Card(PhysicalItem):
|
||||
__tablename__ = "cards"
|
||||
|
||||
id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True)
|
||||
|
||||
__mapper_args__ = {
|
||||
'polymorphic_identity': 'card'
|
||||
}
|
||||
|
||||
class OpenEvent(Base):
|
||||
__tablename__ = "open_events"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
source_item_id = Column(Integer, ForeignKey("physical_items.id"))
|
||||
open_date = Column(DateTime(timezone=True))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source_item_id", name="uq_openevent_one_per_source"),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
source_item = relationship(
|
||||
"PhysicalItem",
|
||||
back_populates="source_open_events",
|
||||
foreign_keys=[source_item_id]
|
||||
)
|
||||
resulting_items = relationship(
|
||||
"PhysicalItem",
|
||||
secondary=open_event_resulting_items,
|
||||
back_populates="resulting_open_events"
|
||||
)
|
||||
|
||||
|
||||
class SealedExpectedValue(Base):
|
||||
__tablename__ = "sealed_expected_values"
|
||||
__table_args__ = (
|
||||
Index('idx_sealed_expected_value_product_id_deleted_at', 'tcgplayer_product_id', 'deleted_at', unique=True),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tcgplayer_product_id = Column(Integer, nullable=False)
|
||||
expected_value = Column(Float, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
product = relationship(
|
||||
"TCGPlayerProduct",
|
||||
primaryjoin="SealedExpectedValue.tcgplayer_product_id == foreign(TCGPlayerProduct.tcgplayer_product_id)",
|
||||
viewonly=True)
|
||||
|
||||
# helper for ev
|
||||
#def assign_expected_value(target, session):
|
||||
# products = target.products
|
||||
# if not products:
|
||||
# raise ValueError(f"No product found for item ID {target.id}")
|
||||
|
||||
# if len(products) > 1:
|
||||
# product_names = [p.name for p in products]
|
||||
# critical_error = CriticalErrorLog(
|
||||
# error_type="multiple_products_found",
|
||||
# error_message=f"Multiple products found when assigning expected value for item ID {target.id} product names {product_names}"
|
||||
# )
|
||||
# session.add(critical_error)
|
||||
# session.commit()
|
||||
# raise ValueError(f"Multiple products found when assigning expected value for item ID {target.id} product names {product_names}")
|
||||
|
||||
# product_id = products[0].tcgplayer_product_id # reliable lookup key
|
||||
|
||||
# expected_value_entry = session.query(SealedExpectedValue).filter(
|
||||
# SealedExpectedValue.tcgplayer_product_id == product_id,
|
||||
# SealedExpectedValue.deleted_at == None
|
||||
# ).order_by(SealedExpectedValue.created_at.desc()).first()
|
||||
|
||||
# if expected_value_entry:
|
||||
# target.expected_value = expected_value_entry.expected_value
|
||||
# else:
|
||||
# critical_error = CriticalErrorLog(
|
||||
# error_type="no_expected_value_found",
|
||||
# error_message=f"No expected value found for product {products[0].name}"
|
||||
# )
|
||||
# session.add(critical_error)
|
||||
# session.commit()
|
||||
# raise ValueError(f"No expected value found for product {products[0].name}")
|
||||
|
||||
|
||||
# event listeners
|
||||
#@event.listens_for(InventoryItem, 'before_insert')
|
||||
#def ev_before_insert(mapper, connection, target):
|
||||
# session = Session.object_session(target)
|
||||
# if session:
|
||||
# assign_expected_value(target, session)
|
||||
|
||||
class TransactionItem(Base):
|
||||
__tablename__ = "transaction_items"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
transaction_id = Column(Integer, ForeignKey("transactions.id"))
|
||||
inventory_item_id = Column(Integer, ForeignKey("inventory_items.id"))
|
||||
unit_price = Column(Float, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
transaction = relationship("Transaction", back_populates="transaction_items")
|
||||
inventory_item = relationship("InventoryItem", back_populates="transaction_items")
|
||||
|
||||
class Vendor(Base):
|
||||
__tablename__ = "vendors"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, index=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
transactions = relationship("Transaction", back_populates="vendors")
|
||||
|
||||
class Customer(Base):
|
||||
__tablename__ = "customers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, index=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
transactions = relationship("Transaction", back_populates="customers")
|
||||
|
||||
class Transaction(Base):
|
||||
__tablename__ = "transactions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=True)
|
||||
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=True)
|
||||
marketplace_id = Column(Integer, ForeignKey("marketplaces.id"), nullable=True)
|
||||
transaction_type = Column(String) # 'purchase' or 'sale'
|
||||
transaction_date = Column(DateTime(timezone=True))
|
||||
transaction_total_amount = Column(Float)
|
||||
transaction_notes = Column(String)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
transaction_items = relationship("TransactionItem", back_populates="transaction")
|
||||
vendors = relationship("Vendor", back_populates="transactions")
|
||||
customers = relationship("Customer", back_populates="transactions")
|
||||
marketplaces = relationship("Marketplace", back_populates="transactions")
|
||||
|
||||
class Marketplace(Base):
|
||||
__tablename__ = "marketplaces"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, index=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
listings = relationship("MarketplaceListing", back_populates="marketplace")
|
||||
transactions = relationship("Transaction", back_populates="marketplaces")
|
||||
class MarketplaceListing(Base):
|
||||
__tablename__ = "marketplace_listings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
inventory_item_id = Column(Integer, ForeignKey("inventory_items.id"), nullable=False)
|
||||
marketplace_id = Column(Integer, ForeignKey("marketplaces.id"), nullable=False)
|
||||
recommended_price_id = Column(Integer, ForeignKey("pricing_events.id"), nullable=True)
|
||||
listed_price_id = Column(Integer, ForeignKey("pricing_events.id"), nullable=True)
|
||||
listing_date = Column(DateTime(timezone=True), nullable=True)
|
||||
delisting_date = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
inventory_item = relationship("InventoryItem", back_populates="marketplace_listing")
|
||||
marketplace = relationship("Marketplace", back_populates="listings")
|
||||
recommended_price = relationship("PricingEvent", foreign_keys=[recommended_price_id])
|
||||
listed_price = relationship("PricingEvent", foreign_keys=[listed_price_id])
|
19
app/models/manabox_import_staging.py
Normal file
19
app/models/manabox_import_staging.py
Normal file
@ -0,0 +1,19 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.db.database import Base
|
||||
|
||||
class ManaboxImportStaging(Base):
|
||||
__tablename__ = "manabox_import_staging"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
file_id = Column(Integer, ForeignKey("files.id"))
|
||||
tcgplayer_product_id = Column(Integer)
|
||||
tcgplayer_sku_id = Column(Integer)
|
||||
quantity = Column(Integer)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
file = relationship("File", back_populates="manabox_import_staging")
|
@ -1,43 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.database import Base
|
||||
|
||||
class MTGJSONCard(Base):
|
||||
__tablename__ = "mtgjson_cards"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
card_id = Column(String, unique=True, index=True)
|
||||
name = Column(String)
|
||||
set_code = Column(String)
|
||||
uuid = Column(String)
|
||||
|
||||
# Identifiers
|
||||
abu_id = Column(String, nullable=True)
|
||||
card_kingdom_etched_id = Column(String, nullable=True)
|
||||
card_kingdom_foil_id = Column(String, nullable=True)
|
||||
card_kingdom_id = Column(String, nullable=True)
|
||||
cardsphere_id = Column(String, nullable=True)
|
||||
cardsphere_foil_id = Column(String, nullable=True)
|
||||
cardtrader_id = Column(String, nullable=True)
|
||||
csi_id = Column(String, nullable=True)
|
||||
mcm_id = Column(String, nullable=True)
|
||||
mcm_meta_id = Column(String, nullable=True)
|
||||
miniaturemarket_id = Column(String, nullable=True)
|
||||
mtg_arena_id = Column(String, nullable=True)
|
||||
mtgjson_foil_version_id = Column(String, nullable=True)
|
||||
mtgjson_non_foil_version_id = Column(String, nullable=True)
|
||||
mtgjson_v4_id = Column(String, nullable=True)
|
||||
mtgo_foil_id = Column(String, nullable=True)
|
||||
mtgo_id = Column(String, nullable=True)
|
||||
multiverse_id = Column(String, nullable=True)
|
||||
scg_id = Column(String, nullable=True)
|
||||
scryfall_id = Column(String, nullable=True)
|
||||
scryfall_card_back_id = Column(String, nullable=True)
|
||||
scryfall_oracle_id = Column(String, nullable=True)
|
||||
scryfall_illustration_id = Column(String, nullable=True)
|
||||
tcgplayer_product_id = Column(String, nullable=True)
|
||||
tcgplayer_etched_product_id = Column(String, nullable=True)
|
||||
tnt_id = Column(String, nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.current_timestamp())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.current_timestamp())
|
@ -1,17 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.database import Base
|
||||
|
||||
class MTGJSONSKU(Base):
|
||||
__tablename__ = "mtgjson_skus"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
sku_id = Column(String, index=True)
|
||||
product_id = Column(String, index=True)
|
||||
condition = Column(String)
|
||||
finish = Column(String)
|
||||
language = Column(String)
|
||||
printing = Column(String)
|
||||
card_id = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.current_timestamp())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.current_timestamp())
|
29
app/models/pricing.py
Normal file
29
app/models/pricing.py
Normal file
@ -0,0 +1,29 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, CheckConstraint, Index, Boolean, Table, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.db.database import Base
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class PricingEvent(Base):
|
||||
__tablename__ = "pricing_events"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
inventory_item_id = Column(Integer, ForeignKey("inventory_items.id"))
|
||||
price = Column(Float)
|
||||
price_used = Column(String)
|
||||
price_reason = Column(String)
|
||||
free_shipping_adjustment = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
inventory_item = relationship(
|
||||
"InventoryItem",
|
||||
primaryjoin="PricingEvent.inventory_item_id == foreign(InventoryItem.id)",
|
||||
viewonly=True
|
||||
)
|
@ -1,23 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.database import Base
|
||||
|
||||
class TCGPlayerCategory(Base):
|
||||
__tablename__ = "tcgplayer_categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
category_id = Column(Integer, unique=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
display_name = Column(String)
|
||||
seo_category_name = Column(String)
|
||||
category_description = Column(String)
|
||||
category_page_title = Column(String)
|
||||
sealed_label = Column(String)
|
||||
non_sealed_label = Column(String)
|
||||
condition_guide_url = Column(String)
|
||||
is_scannable = Column(Boolean, default=False)
|
||||
popularity = Column(Integer, default=0)
|
||||
is_direct = Column(Boolean, default=False)
|
||||
modified_on = Column(DateTime)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
@ -1,17 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.database import Base
|
||||
|
||||
class TCGPlayerGroup(Base):
|
||||
__tablename__ = "tcgplayer_groups"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
group_id = Column(Integer, unique=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
abbreviation = Column(String)
|
||||
is_supplemental = Column(Boolean, default=False)
|
||||
published_on = Column(DateTime)
|
||||
modified_on = Column(DateTime)
|
||||
category_id = Column(Integer)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
61
app/models/tcgplayer_inventory.py
Normal file
61
app/models/tcgplayer_inventory.py
Normal file
@ -0,0 +1,61 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.database import Base
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
class TCGPlayerInventory(Base):
|
||||
__tablename__ = "tcgplayer_inventory"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tcgplayer_sku_id = Column(Integer, unique=True, index=True)
|
||||
product_line = Column(String)
|
||||
set_name = Column(String)
|
||||
product_name = Column(String)
|
||||
title = Column(String)
|
||||
number = Column(String)
|
||||
rarity = Column(String)
|
||||
condition = Column(String)
|
||||
tcg_market_price = Column(Float)
|
||||
tcg_direct_low = Column(Float)
|
||||
tcg_low_price_with_shipping = Column(Float)
|
||||
tcg_low_price = Column(Float)
|
||||
total_quantity = Column(Integer)
|
||||
add_to_quantity = Column(Integer)
|
||||
tcg_marketplace_price = Column(Float)
|
||||
photo_url = Column(String)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# relationships
|
||||
unmanaged_inventory = relationship("UnmanagedTCGPlayerInventory", back_populates="tcgplayer_inventory")
|
||||
sku = relationship("MTGJSONSKU", primaryjoin="foreign(MTGJSONSKU.tcgplayer_sku_id) == TCGPlayerInventory.tcgplayer_sku_id", viewonly=True)
|
||||
|
||||
|
||||
class UnmanagedTCGPlayerInventory(Base):
|
||||
__tablename__ = "unmanaged_tcgplayer_inventory"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tcgplayer_inventory_id = Column(Integer, ForeignKey("tcgplayer_inventory.id"), unique=True, index=True)
|
||||
tcgplayer_sku_id = Column(Integer, unique=True, index=True)
|
||||
product_line = Column(String)
|
||||
set_name = Column(String)
|
||||
product_name = Column(String)
|
||||
title = Column(String)
|
||||
number = Column(String)
|
||||
rarity = Column(String)
|
||||
condition = Column(String)
|
||||
tcg_market_price = Column(Float)
|
||||
tcg_direct_low = Column(Float)
|
||||
tcg_low_price_with_shipping = Column(Float)
|
||||
tcg_low_price = Column(Float)
|
||||
total_quantity = Column(Integer)
|
||||
add_to_quantity = Column(Integer)
|
||||
tcg_marketplace_price = Column(Float)
|
||||
photo_url = Column(String)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# relationships
|
||||
tcgplayer_inventory = relationship("TCGPlayerInventory", back_populates="unmanaged_inventory")
|
||||
sku = relationship("MTGJSONSKU", primaryjoin="foreign(MTGJSONSKU.tcgplayer_sku_id) == UnmanagedTCGPlayerInventory.tcgplayer_sku_id", viewonly=True)
|
||||
|
@ -1,14 +1,14 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, UTC
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, JSON, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.database import Base
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
class TCGPlayerOrder(Base):
|
||||
__tablename__ = "tcgplayer_orders"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_number = Column(String, index=True)
|
||||
order_created_at = Column(DateTime)
|
||||
order_created_at = Column(DateTime(timezone=True))
|
||||
status = Column(String)
|
||||
channel = Column(String)
|
||||
fulfillment = Column(String)
|
||||
@ -17,7 +17,7 @@ class TCGPlayerOrder(Base):
|
||||
payment_type = Column(String)
|
||||
pickup_status = Column(String)
|
||||
shipping_type = Column(String)
|
||||
estimated_delivery_date = Column(DateTime)
|
||||
estimated_delivery_date = Column(DateTime(timezone=True))
|
||||
recipient_name = Column(String)
|
||||
address_line_1 = Column(String)
|
||||
address_line_2 = Column(String)
|
||||
@ -26,8 +26,8 @@ class TCGPlayerOrder(Base):
|
||||
zip_code = Column(String)
|
||||
country = Column(String)
|
||||
tracking_numbers = Column(JSON)
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class TCGPlayerOrderTransaction(Base):
|
||||
@ -42,8 +42,8 @@ class TCGPlayerOrderTransaction(Base):
|
||||
net_amount = Column(Float)
|
||||
direct_fee_amount = Column(Float)
|
||||
taxes = Column(JSON)
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class TCGPlayerOrderProduct(Base):
|
||||
@ -56,17 +56,18 @@ class TCGPlayerOrderProduct(Base):
|
||||
extended_price = Column(Float)
|
||||
quantity = Column(Integer)
|
||||
url = Column(String)
|
||||
product_id = Column(String)
|
||||
sku_id = Column(String)
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
product_id = Column(Integer)
|
||||
sku_id = Column(Integer)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class TCGPlayerOrderRefund(Base):
|
||||
__tablename__ = "tcgplayer_order_refunds"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_number = Column(String, index=True)
|
||||
refund_created_at = Column(DateTime)
|
||||
refund_created_at = Column(DateTime(timezone=True))
|
||||
type = Column(String)
|
||||
amount = Column(Float)
|
||||
type = Column(String)
|
||||
@ -74,5 +75,5 @@ class TCGPlayerOrderRefund(Base):
|
||||
origin = Column(String)
|
||||
shipping_amount = Column(Float)
|
||||
products = Column(JSON)
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
@ -1,27 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.database import Base
|
||||
|
||||
class TCGPlayerProduct(Base):
|
||||
__tablename__ = "tcgplayer_products"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
clean_name = Column(String)
|
||||
image_url = Column(String)
|
||||
category_id = Column(Integer)
|
||||
group_id = Column(Integer, ForeignKey("tcgplayer_groups.group_id"))
|
||||
url = Column(String)
|
||||
modified_on = Column(DateTime)
|
||||
image_count = Column(Integer)
|
||||
ext_rarity = Column(String)
|
||||
ext_number = Column(String)
|
||||
low_price = Column(Float)
|
||||
mid_price = Column(Float)
|
||||
high_price = Column(Float)
|
||||
market_price = Column(Float)
|
||||
direct_low_price = Column(Float)
|
||||
sub_type_name = Column(String)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
295
app/models/tcgplayer_products.py
Normal file
295
app/models/tcgplayer_products.py
Normal file
@ -0,0 +1,295 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Table, Float, Boolean, Index, text, DDL, event, UniqueConstraint, ForeignKeyConstraint
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.database import Base
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
|
||||
# =============================================================================
|
||||
# Core Models
|
||||
# =============================================================================
|
||||
|
||||
class MTGJSONSKU(Base):
|
||||
__tablename__ = "mtgjson_skus"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
mtgjson_uuid = Column(String, ForeignKey("mtgjson_cards.mtgjson_uuid"), index=True)
|
||||
tcgplayer_sku_id = Column(Integer, index=True, unique=True)
|
||||
tcgplayer_product_id = Column(Integer, nullable=False)
|
||||
normalized_printing = Column(String, nullable=False)
|
||||
condition = Column(String)
|
||||
finish = Column(String, nullable=True)
|
||||
language = Column(String)
|
||||
printing = Column(String)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(
|
||||
["tcgplayer_product_id", "normalized_printing"],
|
||||
["tcgplayer_products.tcgplayer_product_id", "tcgplayer_products.normalized_sub_type_name"],
|
||||
name="fk_sku_to_product_composite"
|
||||
),
|
||||
Index('idx_sku_product_printing', 'tcgplayer_product_id', 'normalized_printing'),
|
||||
)
|
||||
|
||||
product = relationship("TCGPlayerProduct", back_populates="skus")
|
||||
physical_items = relationship(
|
||||
"PhysicalItem",
|
||||
back_populates="sku",
|
||||
primaryjoin="PhysicalItem.tcgplayer_sku_id == MTGJSONSKU.tcgplayer_sku_id"
|
||||
)
|
||||
card = relationship("MTGJSONCard", back_populates="skus")
|
||||
|
||||
|
||||
class MTGJSONCard(Base):
|
||||
"""Represents a Magic: The Gathering card."""
|
||||
__tablename__ = "mtgjson_cards"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
mtgjson_uuid = Column(String, ForeignKey("mtgjson_cards.mtgjson_uuid"), unique=True, index=True)
|
||||
name = Column(String)
|
||||
set_code = Column(String)
|
||||
abu_id = Column(String, nullable=True)
|
||||
card_kingdom_etched_id = Column(String, nullable=True)
|
||||
card_kingdom_foil_id = Column(String, nullable=True)
|
||||
card_kingdom_id = Column(String, nullable=True)
|
||||
cardsphere_id = Column(String, nullable=True)
|
||||
cardsphere_foil_id = Column(String, nullable=True)
|
||||
cardtrader_id = Column(String, nullable=True)
|
||||
csi_id = Column(String, nullable=True)
|
||||
mcm_id = Column(String, nullable=True)
|
||||
mcm_meta_id = Column(String, nullable=True)
|
||||
miniaturemarket_id = Column(String, nullable=True)
|
||||
mtg_arena_id = Column(String, nullable=True)
|
||||
mtgjson_foil_version_id = Column(String, nullable=True)
|
||||
mtgjson_non_foil_version_id = Column(String, nullable=True)
|
||||
mtgjson_v4_id = Column(String, nullable=True)
|
||||
mtgo_foil_id = Column(String, nullable=True)
|
||||
mtgo_id = Column(String, nullable=True)
|
||||
multiverse_id = Column(String, nullable=True)
|
||||
scg_id = Column(String, nullable=True)
|
||||
scryfall_id = Column(String, nullable=True)
|
||||
scryfall_card_back_id = Column(String, nullable=True)
|
||||
scryfall_oracle_id = Column(String, nullable=True)
|
||||
scryfall_illustration_id = Column(String, nullable=True)
|
||||
tcgplayer_product_id = Column(Integer, nullable=True)
|
||||
tcgplayer_etched_product_id = Column(Integer, nullable=True)
|
||||
tnt_id = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("mtgjson_uuid", name="uq_card_mtgjson_uuid"),
|
||||
)
|
||||
|
||||
skus = relationship("MTGJSONSKU", back_populates="card")
|
||||
|
||||
|
||||
class TCGPlayerProduct(Base):
|
||||
"""Represents a higher-level TCGPlayer product concept."""
|
||||
__tablename__ = "tcgplayer_products"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tcgplayer_product_id = Column(Integer, nullable=False)
|
||||
normalized_sub_type_name = Column(String, nullable=False) # normalized for FK
|
||||
sub_type_name = Column(String) # original unnormalized field
|
||||
|
||||
name = Column(String, nullable=False)
|
||||
clean_name = Column(String)
|
||||
image_url = Column(String)
|
||||
category_id = Column(Integer, ForeignKey("tcgplayer_categories.category_id"))
|
||||
group_id = Column(Integer, ForeignKey("tcgplayer_groups.group_id"))
|
||||
url = Column(String)
|
||||
modified_on = Column(DateTime)
|
||||
image_count = Column(Integer)
|
||||
ext_rarity = Column(String)
|
||||
ext_subtype = Column(String)
|
||||
ext_oracle_text = Column(String)
|
||||
ext_number = Column(String)
|
||||
low_price = Column(Float)
|
||||
mid_price = Column(Float)
|
||||
high_price = Column(Float)
|
||||
market_price = Column(Float)
|
||||
direct_low_price = Column(Float)
|
||||
ext_power = Column(String)
|
||||
ext_toughness = Column(String)
|
||||
ext_flavor_text = Column(String)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Enforce uniqueness for composite key
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tcgplayer_product_id", "normalized_sub_type_name", name="uq_product_subtype"),
|
||||
Index('idx_product_subtype', 'tcgplayer_product_id', 'normalized_sub_type_name'),
|
||||
)
|
||||
|
||||
# Backref to SKUs that link via composite FK
|
||||
skus = relationship("MTGJSONSKU", back_populates="product")
|
||||
physical_items_direct = relationship("PhysicalItem",
|
||||
back_populates="product_direct",
|
||||
primaryjoin="foreign(TCGPlayerProduct.tcgplayer_product_id) == PhysicalItem.tcgplayer_product_id",
|
||||
viewonly=True,
|
||||
uselist=False)
|
||||
category = relationship("TCGPlayerCategory", back_populates="products")
|
||||
group = relationship("TCGPlayerGroup", back_populates="products")
|
||||
price_history = relationship("TCGPlayerPriceHistory",
|
||||
back_populates="product",
|
||||
primaryjoin="and_(foreign(TCGPlayerProduct.tcgplayer_product_id) == TCGPlayerPriceHistory.product_id, "
|
||||
"foreign(TCGPlayerProduct.sub_type_name) == TCGPlayerPriceHistory.sub_type_name)")
|
||||
|
||||
most_recent_tcgplayer_price = relationship("MostRecentTCGPlayerPrice",
|
||||
back_populates="product",
|
||||
primaryjoin="and_(foreign(TCGPlayerProduct.tcgplayer_product_id) == MostRecentTCGPlayerPrice.product_id, "
|
||||
"foreign(TCGPlayerProduct.sub_type_name) == MostRecentTCGPlayerPrice.sub_type_name)")
|
||||
|
||||
sealed_expected_value = relationship("SealedExpectedValue",
|
||||
primaryjoin="and_(TCGPlayerProduct.tcgplayer_product_id == foreign(SealedExpectedValue.tcgplayer_product_id), "
|
||||
"foreign(SealedExpectedValue.deleted_at) == None)",
|
||||
viewonly=True,
|
||||
uselist=False)
|
||||
# =============================================================================
|
||||
# Supporting Models
|
||||
# =============================================================================
|
||||
|
||||
class TCGPlayerCategory(Base):
|
||||
"""Represents a TCGPlayer product category."""
|
||||
__tablename__ = "tcgplayer_categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
category_id = Column(Integer, unique=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
display_name = Column(String)
|
||||
seo_category_name = Column(String)
|
||||
category_description = Column(String)
|
||||
category_page_title = Column(String)
|
||||
sealed_label = Column(String)
|
||||
non_sealed_label = Column(String)
|
||||
condition_guide_url = Column(String)
|
||||
is_scannable = Column(Boolean, default=False)
|
||||
popularity = Column(Integer, default=0)
|
||||
is_direct = Column(Boolean, default=False)
|
||||
modified_on = Column(DateTime)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
products = relationship("TCGPlayerProduct", back_populates="category")
|
||||
|
||||
class TCGPlayerGroup(Base):
|
||||
"""Represents a TCGPlayer product group."""
|
||||
__tablename__ = "tcgplayer_groups"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
group_id = Column(Integer, unique=True, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
abbreviation = Column(String)
|
||||
is_supplemental = Column(Boolean, default=False)
|
||||
published_on = Column(DateTime)
|
||||
modified_on = Column(DateTime)
|
||||
category_id = Column(Integer, ForeignKey("tcgplayer_categories.category_id"))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
products = relationship("TCGPlayerProduct", back_populates="group")
|
||||
|
||||
class TCGPlayerPriceHistory(Base):
|
||||
"""Represents the price history for a product variant (foil/non-foil).
|
||||
|
||||
Each product has exactly two price history records - one for foil and one for non-foil.
|
||||
The relationship to TCGPlayerProduct is effectively 1:1 when considering both product_id
|
||||
and sub_type_name.
|
||||
"""
|
||||
__tablename__ = "tcgplayer_price_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, nullable=False, index=True)
|
||||
sub_type_name = Column(String, index=True) # This indicates foil/non-foil
|
||||
date = Column(DateTime, index=True)
|
||||
low_price = Column(Float)
|
||||
mid_price = Column(Float)
|
||||
high_price = Column(Float)
|
||||
market_price = Column(Float)
|
||||
direct_low_price = Column(Float)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Add a unique constraint on (product_id, sub_type_name, date) to prevent duplicate entries
|
||||
__table_args__ = (
|
||||
Index('idx_price_history_product_subtype_date', 'product_id', 'sub_type_name', 'date'),
|
||||
)
|
||||
|
||||
product = relationship("TCGPlayerProduct",
|
||||
back_populates="price_history",
|
||||
primaryjoin="and_(TCGPlayerPriceHistory.product_id == foreign(TCGPlayerProduct.tcgplayer_product_id), "
|
||||
"TCGPlayerPriceHistory.sub_type_name == foreign(TCGPlayerProduct.sub_type_name))")
|
||||
|
||||
class MostRecentTCGPlayerPrice(Base):
|
||||
"""Represents the most recent price for a product.
|
||||
THIS ISNT A MATERIALIZED VIEW ANYMORE FUCK IT
|
||||
"""
|
||||
__tablename__ = "most_recent_tcgplayer_price"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
product_id = Column(Integer, nullable=False, index=True)
|
||||
sub_type_name = Column(String, index=True) # This indicates foil/non-foil
|
||||
date = Column(DateTime, nullable=False)
|
||||
low_price = Column(Float)
|
||||
mid_price = Column(Float)
|
||||
high_price = Column(Float)
|
||||
market_price = Column(Float)
|
||||
direct_low_price = Column(Float)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_most_recent_price_product_subtype', 'product_id', 'sub_type_name', unique=True),
|
||||
)
|
||||
|
||||
product = relationship("TCGPlayerProduct",
|
||||
back_populates="most_recent_tcgplayer_price",
|
||||
primaryjoin="and_(MostRecentTCGPlayerPrice.product_id == foreign(TCGPlayerProduct.tcgplayer_product_id), "
|
||||
"MostRecentTCGPlayerPrice.sub_type_name == foreign(TCGPlayerProduct.sub_type_name))")
|
||||
|
||||
@classmethod
|
||||
def update_most_recent_prices(cls, db: Session) -> None:
|
||||
"""Update the most recent prices from the price history table."""
|
||||
# Delete all existing records
|
||||
db.query(cls).delete()
|
||||
|
||||
|
||||
# Get the most recent price for each product and sub_type_name
|
||||
subquery = db.query(
|
||||
TCGPlayerPriceHistory.product_id,
|
||||
TCGPlayerPriceHistory.sub_type_name,
|
||||
func.max(TCGPlayerPriceHistory.date).label('max_date')
|
||||
).group_by(
|
||||
TCGPlayerPriceHistory.product_id,
|
||||
TCGPlayerPriceHistory.sub_type_name
|
||||
).subquery()
|
||||
|
||||
# Join with price history to get the full records
|
||||
latest_prices = db.query(TCGPlayerPriceHistory).join(
|
||||
subquery,
|
||||
and_(
|
||||
TCGPlayerPriceHistory.product_id == subquery.c.product_id,
|
||||
TCGPlayerPriceHistory.sub_type_name == subquery.c.sub_type_name,
|
||||
TCGPlayerPriceHistory.date == subquery.c.max_date
|
||||
)
|
||||
).all()
|
||||
|
||||
# Create new MostRecentTCGPlayerPrice records
|
||||
for price in latest_prices:
|
||||
most_recent = cls(
|
||||
product_id=price.product_id,
|
||||
sub_type_name=price.sub_type_name,
|
||||
date=price.date,
|
||||
low_price=price.low_price,
|
||||
mid_price=price.mid_price,
|
||||
high_price=price.high_price,
|
||||
market_price=price.market_price,
|
||||
direct_low_price=price.direct_low_price
|
||||
)
|
||||
db.add(most_recent)
|
||||
|
||||
db.commit()
|
504
app/routes/inventory_management_routes.py
Normal file
504
app/routes/inventory_management_routes.py
Normal file
@ -0,0 +1,504 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, func
|
||||
from app.db.database import get_db
|
||||
from app.services.service_manager import ServiceManager
|
||||
from app.contexts.inventory_item import InventoryItemContextFactory
|
||||
from app.schemas.transaction import PurchaseTransactionCreate, SaleTransactionCreate, SealedExpectedValueCreate, GetAllTransactionsResponse, TransactionResponse, TransactionItemResponse, InventoryItemResponse, TCGPlayerProductResponse, OpenEventResponse, OpenEventCreate, OpenEventResultingItemsResponse, OpenEventsForInventoryItemResponse
|
||||
from app.models.inventory_management import Transaction
|
||||
from app.models.tcgplayer_products import TCGPlayerProduct
|
||||
from typing import List
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
router = APIRouter(prefix="/inventory")
|
||||
|
||||
service_manager = ServiceManager()
|
||||
|
||||
# Sealed Routes
|
||||
@router.get("/sealed/boxes")
|
||||
async def get_sealed_boxes(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
sealed_box_service = service_manager.get_service("sealed_box")
|
||||
raw_boxes = sealed_box_service.get_all(db, skip=skip, limit=limit)
|
||||
boxes = []
|
||||
for box in raw_boxes:
|
||||
inventory_item = InventoryItemContextFactory(db).get_context(box.inventory_item)
|
||||
boxes.append(inventory_item)
|
||||
return boxes
|
||||
|
||||
@router.get("/sealed/boxes/{sealed_box_id}")
|
||||
async def get_sealed_box(
|
||||
sealed_box_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
sealed_box_service = service_manager.get_service("sealed_box")
|
||||
sealed_box = sealed_box_service.get(db, sealed_box_id)
|
||||
return InventoryItemContextFactory(db).get_context(sealed_box.inventory_item)
|
||||
|
||||
@router.post("/sealed/boxes")
|
||||
async def create_sealed_box(
|
||||
product_id: int,
|
||||
cost_basis: float,
|
||||
case_id: int = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
sealed_box_service = service_manager.get_service("sealed_box")
|
||||
sealed_box = await sealed_box_service.create_sealed_box(db, product_id, cost_basis, case_id)
|
||||
return InventoryItemContextFactory(db).get_context(sealed_box.inventory_item)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/sealed/boxes/{sealed_box_id}/open")
|
||||
async def open_sealed_box(
|
||||
sealed_box_id: int,
|
||||
manabox_file_upload_ids: List[int],
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
sealed_box_service = service_manager.get_service("sealed_box")
|
||||
file_service = service_manager.get_service("file")
|
||||
sealed_box = sealed_box_service.get(db, sealed_box_id)
|
||||
manabox_file_uploads = []
|
||||
for manabox_file_upload_id in manabox_file_upload_ids:
|
||||
manabox_file_upload = file_service.get_file(db, manabox_file_upload_id)
|
||||
manabox_file_uploads.append(manabox_file_upload)
|
||||
success = await inventory_service.process_manabox_import_staging(db, manabox_file_uploads, sealed_box)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to process Manabox import staging")
|
||||
return {"message": "Manabox import staging processed successfully"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/sealed/cases")
|
||||
async def get_sealed_cases(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
sealed_case_service = service_manager.get_service("sealed_case")
|
||||
raw_cases = sealed_case_service.get_all(db, skip=skip, limit=limit)
|
||||
cases = []
|
||||
for case in raw_cases:
|
||||
inventory_item = InventoryItemContextFactory(db).get_context(case.inventory_item)
|
||||
cases.append(inventory_item)
|
||||
return cases
|
||||
|
||||
@router.get("/sealed/cases/{sealed_case_id}")
|
||||
async def get_sealed_case(
|
||||
sealed_case_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
sealed_case_service = service_manager.get_service("sealed_case")
|
||||
sealed_case = sealed_case_service.get(db, sealed_case_id)
|
||||
return InventoryItemContextFactory(db).get_context(sealed_case.inventory_item)
|
||||
|
||||
@router.post("/sealed/cases")
|
||||
async def create_sealed_case(
|
||||
product_id: int,
|
||||
cost_basis: float,
|
||||
num_boxes: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
sealed_case_service = service_manager.get_service("sealed_case")
|
||||
sealed_case = await sealed_case_service.create_sealed_case(db, product_id, cost_basis, num_boxes)
|
||||
return InventoryItemContextFactory(db).get_context(sealed_case.inventory_item)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/sealed/cases/{sealed_case_id}/open")
|
||||
async def open_sealed_case(
|
||||
sealed_case_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
sealed_case_service = service_manager.get_service("sealed_case")
|
||||
sealed_case = sealed_case_service.get(db, sealed_case_id)
|
||||
await sealed_case_service.open_sealed_case(db, sealed_case)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Transaction Routes
|
||||
@router.post("/transactions/purchase")
|
||||
async def create_purchase_transaction(
|
||||
transaction_data: PurchaseTransactionCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
transaction = await inventory_service.create_purchase_transaction(db, transaction_data)
|
||||
return transaction
|
||||
|
||||
@router.post("/transactions/sale")
|
||||
async def create_sale_transaction(
|
||||
transaction_data: SaleTransactionCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
transaction = await inventory_service.create_sale_transaction(db, transaction_data)
|
||||
return transaction
|
||||
|
||||
@router.post("/customers")
|
||||
async def create_customer(
|
||||
customer_name: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
customer = await inventory_service.create_customer(db, customer_name)
|
||||
return customer
|
||||
|
||||
@router.post("/vendors")
|
||||
async def create_vendor(
|
||||
vendor_name: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
vendor = await inventory_service.create_vendor(db, vendor_name)
|
||||
return vendor
|
||||
|
||||
@router.get("/vendors")
|
||||
async def get_vendors(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
vendors = await inventory_service.get_vendors(db)
|
||||
return vendors
|
||||
|
||||
@router.post("/marketplaces")
|
||||
async def create_marketplace(
|
||||
marketplace_name: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
marketplace = await inventory_service.create_marketplace(db, marketplace_name)
|
||||
return marketplace
|
||||
|
||||
@router.get("/marketplaces")
|
||||
async def get_marketplaces(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
marketplaces = await inventory_service.get_marketplaces(db)
|
||||
return marketplaces
|
||||
|
||||
@router.get("/products/search")
|
||||
async def get_products(q: str, db: Session = Depends(get_db)):
|
||||
query = ' & '.join(q.lower().split()) # This ensures all terms must match
|
||||
|
||||
products = db.query(TCGPlayerProduct).filter(
|
||||
func.to_tsvector('english', TCGPlayerProduct.name)
|
||||
.op('@@')(func.to_tsquery('english', query))
|
||||
).all()
|
||||
|
||||
return products
|
||||
|
||||
@router.get("/products/{product_id}/expected-value")
|
||||
async def get_expected_value(
|
||||
product_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
expected_value = await inventory_service.get_expected_value(db, product_id)
|
||||
return expected_value
|
||||
|
||||
|
||||
@router.post("/products/expected-value")
|
||||
async def create_expected_value(
|
||||
expected_value_data: SealedExpectedValueCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
expected_value = await inventory_service.create_expected_value(db, expected_value_data)
|
||||
return expected_value
|
||||
|
||||
@router.post("/transactions/purchase")
|
||||
async def create_purchase_transaction(
|
||||
transaction_data: PurchaseTransactionCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
transaction = await inventory_service.create_purchase_transaction(db, transaction_data)
|
||||
return transaction
|
||||
|
||||
@router.get("/transactions")
|
||||
async def get_transactions(
|
||||
db: Session = Depends(get_db),
|
||||
skip: int = 0,
|
||||
limit: int = 100
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
total = db.query(func.count(Transaction.id)).filter(Transaction.deleted_at == None).scalar()
|
||||
transactions = await inventory_service.get_transactions(db, skip, limit)
|
||||
return GetAllTransactionsResponse(
|
||||
total=total,
|
||||
transactions=[TransactionResponse(
|
||||
id=transaction.id,
|
||||
vendor_id=transaction.vendor_id,
|
||||
customer_id=transaction.customer_id,
|
||||
marketplace_id=transaction.marketplace_id,
|
||||
transaction_type=transaction.transaction_type,
|
||||
transaction_date=transaction.transaction_date,
|
||||
transaction_total_amount=transaction.transaction_total_amount,
|
||||
transaction_notes=transaction.transaction_notes,
|
||||
created_at=transaction.created_at,
|
||||
updated_at=transaction.updated_at,
|
||||
transaction_items=[TransactionItemResponse(
|
||||
id=transaction_item.id,
|
||||
transaction_id=transaction_item.transaction_id,
|
||||
inventory_item_id=transaction_item.inventory_item_id,
|
||||
unit_price=transaction_item.unit_price,
|
||||
created_at=transaction_item.created_at,
|
||||
updated_at=transaction_item.updated_at,
|
||||
deleted_at=transaction_item.deleted_at
|
||||
) for transaction_item in transaction.transaction_items]
|
||||
) for transaction in transactions]
|
||||
)
|
||||
|
||||
@router.get("/transactions/{transaction_id}")
|
||||
async def get_transaction(
|
||||
transaction_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
transaction = await inventory_service.get_transaction(db, transaction_id)
|
||||
if not transaction:
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
return TransactionResponse(
|
||||
id=transaction.id,
|
||||
vendor_id=transaction.vendor_id,
|
||||
customer_id=transaction.customer_id,
|
||||
marketplace_id=transaction.marketplace_id,
|
||||
transaction_type=transaction.transaction_type,
|
||||
transaction_date=transaction.transaction_date,
|
||||
transaction_total_amount=transaction.transaction_total_amount,
|
||||
transaction_notes=transaction.transaction_notes,
|
||||
created_at=transaction.created_at,
|
||||
updated_at=transaction.updated_at,
|
||||
transaction_items=[TransactionItemResponse(
|
||||
id=transaction_item.id,
|
||||
transaction_id=transaction_item.transaction_id,
|
||||
inventory_item_id=transaction_item.inventory_item_id,
|
||||
unit_price=transaction_item.unit_price,
|
||||
created_at=transaction_item.created_at,
|
||||
updated_at=transaction_item.updated_at,
|
||||
deleted_at=transaction_item.deleted_at
|
||||
) for transaction_item in transaction.transaction_items]
|
||||
)
|
||||
|
||||
@router.get("/items/{inventory_item_id}")
|
||||
async def get_inventory_item(
|
||||
inventory_item_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
inventory_item = await inventory_service.get_inventory_item(db, inventory_item_id)
|
||||
marketplace_listing_service = service_manager.get_service("marketplace_listing")
|
||||
marketplace = await inventory_service.create_marketplace(db, "Tcgplayer")
|
||||
marketplace_listing = await marketplace_listing_service.get_marketplace_listing(db, inventory_item, marketplace)
|
||||
if marketplace_listing is None:
|
||||
listed_price = None
|
||||
recommended_price = None
|
||||
marketplace_listing_id = None
|
||||
else:
|
||||
if marketplace_listing.listed_price is not None:
|
||||
listed_price = marketplace_listing.listed_price.price if marketplace_listing.listed_price.price is not None else None
|
||||
else:
|
||||
listed_price = None
|
||||
if marketplace_listing.recommended_price is not None:
|
||||
recommended_price = marketplace_listing.recommended_price.price if marketplace_listing.recommended_price.price is not None else None
|
||||
else:
|
||||
recommended_price = None
|
||||
marketplace_listing_id = marketplace_listing.id
|
||||
return InventoryItemResponse(
|
||||
id=inventory_item.id,
|
||||
physical_item_id=inventory_item.physical_item_id,
|
||||
cost_basis=inventory_item.cost_basis,
|
||||
parent_id=inventory_item.parent_id,
|
||||
created_at=inventory_item.created_at,
|
||||
updated_at=inventory_item.updated_at,
|
||||
item_type=inventory_item.physical_item.item_type,
|
||||
listed_price=listed_price,
|
||||
recommended_price=recommended_price,
|
||||
marketplace_listing_id=marketplace_listing_id,
|
||||
product=TCGPlayerProductResponse(
|
||||
id=inventory_item.physical_item.product_direct.id,
|
||||
tcgplayer_product_id=inventory_item.physical_item.product_direct.tcgplayer_product_id,
|
||||
name=inventory_item.physical_item.product_direct.name,
|
||||
image_url=inventory_item.physical_item.product_direct.image_url,
|
||||
category_id=inventory_item.physical_item.product_direct.category_id,
|
||||
group_id=inventory_item.physical_item.product_direct.group_id,
|
||||
url=inventory_item.physical_item.product_direct.url,
|
||||
market_price=inventory_item.physical_item.product_direct.most_recent_tcgplayer_price.market_price,
|
||||
category_name=inventory_item.physical_item.product_direct.category.name,
|
||||
group_name=inventory_item.physical_item.product_direct.group.name
|
||||
)
|
||||
)
|
||||
|
||||
@router.post("/items/{inventory_item_id}/open")
|
||||
async def open_box_or_case(
|
||||
open_event_data: OpenEventCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
inventory_item = await inventory_service.get_inventory_item(db, open_event_data.inventory_item_id)
|
||||
file_service = service_manager.get_service("file")
|
||||
files = [await file_service.get_file(db, file_id) for file_id in open_event_data.manabox_file_upload_ids]
|
||||
if inventory_item.physical_item.item_type == "box":
|
||||
box_service = service_manager.get_service("box")
|
||||
open_event = await box_service.open_box(db, inventory_item.physical_item, files)
|
||||
return OpenEventResponse(
|
||||
id=open_event.id,
|
||||
source_item_id=open_event.source_item_id,
|
||||
created_at=open_event.created_at,
|
||||
updated_at=open_event.updated_at
|
||||
)
|
||||
elif inventory_item.physical_item.item_type == "case":
|
||||
case_service = service_manager.get_service("case")
|
||||
open_event = await case_service.open_case(db, inventory_item.physical_item, files)
|
||||
return OpenEventResponse(
|
||||
id=open_event.id,
|
||||
source_item_id=open_event.source_item_id,
|
||||
created_at=open_event.created_at,
|
||||
updated_at=open_event.updated_at
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Invalid item type")
|
||||
|
||||
@router.get("/items/{inventory_item_id}/open-events")
|
||||
async def get_open_events(
|
||||
inventory_item_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
inventory_item = await inventory_service.get_inventory_item(db, inventory_item_id)
|
||||
|
||||
# Don't return open events for cards
|
||||
if inventory_item.physical_item.item_type == 'card':
|
||||
return OpenEventsForInventoryItemResponse(open_events=[])
|
||||
|
||||
open_events = await inventory_service.get_open_events_for_inventory_item(db, inventory_item)
|
||||
return OpenEventsForInventoryItemResponse(
|
||||
open_events=[OpenEventResponse(
|
||||
id=open_event.id,
|
||||
source_item_id=open_event.source_item_id,
|
||||
created_at=open_event.created_at,
|
||||
updated_at=open_event.updated_at
|
||||
) for open_event in open_events]
|
||||
)
|
||||
|
||||
@router.get("/items/{inventory_item_id}/open-events/{open_event_id}/resulting-items", response_model=List[InventoryItemResponse])
|
||||
async def get_resulting_items(
|
||||
inventory_item_id: int,
|
||||
open_event_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
inventory_item = await inventory_service.get_inventory_item(db, inventory_item_id)
|
||||
open_event = await inventory_service.get_open_event(db, inventory_item, open_event_id)
|
||||
resulting_items = await inventory_service.get_resulting_items_for_open_event(db, open_event)
|
||||
marketplace_listing_service = service_manager.get_service("marketplace_listing")
|
||||
marketplace = await inventory_service.create_marketplace(db, "Tcgplayer")
|
||||
marketplace_listing = await marketplace_listing_service.get_marketplace_listing(db, inventory_item, marketplace)
|
||||
if marketplace_listing is None:
|
||||
listed_price = None
|
||||
recommended_price = None
|
||||
marketplace_listing_id = None
|
||||
else:
|
||||
if marketplace_listing.listed_price is not None:
|
||||
listed_price = marketplace_listing.listed_price.price if marketplace_listing.listed_price.price is not None else None
|
||||
else:
|
||||
listed_price = None
|
||||
if marketplace_listing.recommended_price is not None:
|
||||
recommended_price = marketplace_listing.recommended_price.price if marketplace_listing.recommended_price.price is not None else None
|
||||
else:
|
||||
recommended_price = None
|
||||
marketplace_listing_id = marketplace_listing.id
|
||||
return [InventoryItemResponse(
|
||||
id=resulting_item.id,
|
||||
physical_item_id=resulting_item.physical_item_id,
|
||||
cost_basis=resulting_item.cost_basis,
|
||||
parent_id=resulting_item.parent_id,
|
||||
product=TCGPlayerProductResponse(
|
||||
id=resulting_item.physical_item.product_direct.id,
|
||||
tcgplayer_product_id=resulting_item.physical_item.product_direct.tcgplayer_product_id,
|
||||
name=resulting_item.physical_item.product_direct.name,
|
||||
image_url=resulting_item.physical_item.product_direct.image_url,
|
||||
category_id=resulting_item.physical_item.product_direct.category_id,
|
||||
group_id=resulting_item.physical_item.product_direct.group_id,
|
||||
url=resulting_item.physical_item.product_direct.url,
|
||||
market_price=resulting_item.physical_item.product_direct.most_recent_tcgplayer_price.market_price,
|
||||
category_name=resulting_item.physical_item.product_direct.category.name,
|
||||
group_name=resulting_item.physical_item.product_direct.group.name
|
||||
),
|
||||
item_type=resulting_item.physical_item.item_type,
|
||||
marketplace_listing_id=marketplace_listing_id,
|
||||
listed_price=listed_price,
|
||||
recommended_price=recommended_price,
|
||||
created_at=resulting_item.created_at,
|
||||
updated_at=resulting_item.updated_at) for resulting_item in resulting_items]
|
||||
|
||||
@router.post("/items/{inventory_item_id}/open-events/{open_event_id}/create-listings")
|
||||
async def create_marketplace_listings(
|
||||
inventory_item_id: int,
|
||||
open_event_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
inventory_item = await inventory_service.get_inventory_item(db, inventory_item_id)
|
||||
open_event = await inventory_service.get_open_event(db, inventory_item, open_event_id)
|
||||
resulting_items = await inventory_service.get_resulting_items_for_open_event(db, open_event)
|
||||
marketplace = await inventory_service.create_marketplace(db, "Tcgplayer")
|
||||
marketplace_listing_service = service_manager.get_service("marketplace_listing")
|
||||
for resulting_item in resulting_items:
|
||||
await marketplace_listing_service.create_marketplace_listing(db, resulting_item, marketplace)
|
||||
return {"message": f"{len(resulting_items)} marketplace listings created successfully"}
|
||||
|
||||
@router.post("/items/{inventory_item_id}/open-events/{open_event_id}/confirm-listings")
|
||||
async def confirm_listings(
|
||||
inventory_item_id: int,
|
||||
open_event_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
inventory_item = await inventory_service.get_inventory_item(db, inventory_item_id)
|
||||
open_event = await inventory_service.get_open_event(db, inventory_item, open_event_id)
|
||||
marketplace_listing_service = service_manager.get_service("marketplace_listing")
|
||||
marketplace = await inventory_service.create_marketplace(db, "Tcgplayer")
|
||||
try:
|
||||
csv_string = await marketplace_listing_service.confirm_listings(db, open_event, marketplace)
|
||||
if not csv_string:
|
||||
raise ValueError("No CSV data generated")
|
||||
|
||||
# Create a streaming response with the CSV data
|
||||
return StreamingResponse(
|
||||
iter([csv_string]),
|
||||
media_type="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=tcgplayer_add_file_{open_event.id}_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.csv"
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/tcgplayer/update-file")
|
||||
async def get_tcgplayer_update_file(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_service = service_manager.get_service("inventory")
|
||||
marketplace_listing_service = service_manager.get_service("marketplace_listing")
|
||||
marketplace = await inventory_service.create_marketplace(db, "Tcgplayer")
|
||||
csv_string = await marketplace_listing_service.create_tcgplayer_update_file(db, marketplace)
|
||||
return StreamingResponse(
|
||||
iter([csv_string]),
|
||||
media_type="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=tcgplayer_update_file_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.csv"
|
||||
}
|
||||
)
|
94
app/routes/manabox_routes.py
Normal file
94
app/routes/manabox_routes.py
Normal file
@ -0,0 +1,94 @@
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Form, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import get_db
|
||||
from app.services.service_manager import ServiceManager
|
||||
import csv
|
||||
import io
|
||||
|
||||
router = APIRouter(prefix="/manabox")
|
||||
|
||||
service_manager = ServiceManager()
|
||||
|
||||
REQUIRED_HEADERS = {
|
||||
'Name', 'Set code', 'Set name', 'Collector number', 'Foil', 'Rarity',
|
||||
'Quantity', 'ManaBox ID', 'Scryfall ID', 'Purchase price', 'Misprint',
|
||||
'Altered', 'Condition', 'Language', 'Purchase price currency'
|
||||
}
|
||||
|
||||
def is_valid_csv(file: UploadFile) -> tuple[bool, str]:
|
||||
# Check if filename ends with .csv
|
||||
if not file.filename.lower().endswith('.csv'):
|
||||
return False, "File must have a .csv extension"
|
||||
|
||||
try:
|
||||
# Try to read the content as CSV
|
||||
content = file.file.read()
|
||||
# Reset file pointer for later use
|
||||
file.file.seek(0)
|
||||
|
||||
# Try to parse the content as CSV
|
||||
csv_reader = csv.reader(io.StringIO(content.decode('utf-8')))
|
||||
|
||||
# Get headers from first row
|
||||
headers = set(next(csv_reader))
|
||||
|
||||
# Check for missing headers
|
||||
missing_headers = REQUIRED_HEADERS - headers
|
||||
if missing_headers:
|
||||
return False, f"Missing required columns: {', '.join(missing_headers)}"
|
||||
|
||||
# Check for extra headers
|
||||
extra_headers = headers - REQUIRED_HEADERS
|
||||
if extra_headers:
|
||||
return False, f"Unexpected columns found: {', '.join(extra_headers)}"
|
||||
|
||||
return True, "Valid CSV format"
|
||||
except (csv.Error, UnicodeDecodeError) as e:
|
||||
return False, f"Invalid CSV format: {str(e)}"
|
||||
except StopIteration:
|
||||
return False, "Empty file"
|
||||
|
||||
@router.post("/process-csv")
|
||||
async def process_manabox_csv(
|
||||
background_tasks: BackgroundTasks,
|
||||
file: UploadFile = File(...),
|
||||
source: str = Form(...),
|
||||
description: str = Form(...),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
is_valid, error_message = is_valid_csv(file)
|
||||
if not is_valid:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=error_message
|
||||
)
|
||||
|
||||
content = await file.read()
|
||||
|
||||
metadata = {
|
||||
"source": source,
|
||||
"description": description
|
||||
}
|
||||
|
||||
manabox_service = service_manager.get_service("manabox")
|
||||
|
||||
success = await manabox_service.process_manabox_csv(db, content, metadata, background_tasks)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Failed to process CSV file")
|
||||
|
||||
return {"message": "CSV processed successfully"}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/manabox-file-uploads")
|
||||
async def get_manabox_file_uploads(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
file_service = service_manager.get_service("file")
|
||||
files = await file_service.list_files(db, skip=skip, limit=limit, file_type="manabox")
|
||||
return files
|
@ -1,12 +1,15 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from app.schemas.tcgplayer import TCGPlayerAPIOrderSummary, TCGPlayerAPIOrder
|
||||
from app.schemas.generate import GenerateRequest, GenerateAddressLabelsRequest, GeneratePackingSlipsRequest, GeneratePullSheetsRequest, GenerateResponse
|
||||
from app.schemas.generate import GenerateAddressLabelsRequest, GeneratePackingSlipsRequest, GeneratePullSheetsRequest, GenerateResponse, GenerateReturnLabelsRequest
|
||||
from app.services.service_manager import ServiceManager
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import get_db
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SearchRange(str, Enum):
|
||||
@ -29,7 +32,8 @@ router = APIRouter(prefix="/orders")
|
||||
@router.get("/", response_model=List[TCGPlayerAPIOrderSummary])
|
||||
async def get_orders(
|
||||
search_range: SearchRange = SearchRange.LAST_THREE_MONTHS,
|
||||
open_only: bool = False
|
||||
open_only: bool = False,
|
||||
db: Session = Depends(get_db)
|
||||
) -> List[TCGPlayerAPIOrderSummary]:
|
||||
"""
|
||||
Retrieve orders from TCGPlayer based on search criteria.
|
||||
@ -44,6 +48,7 @@ async def get_orders(
|
||||
try:
|
||||
order_management = service_manager.get_service('order_management')
|
||||
orders = await order_management.get_orders(search_range, open_only)
|
||||
orders = await order_management.add_item_quantity(db, orders)
|
||||
return orders
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch orders: {str(e)}")
|
||||
@ -153,3 +158,88 @@ async def generate_address_labels(
|
||||
return {"success": False, "message": "Address labels not found"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to generate address labels: {str(e)}")
|
||||
|
||||
@router.post("/generate-return-labels")
|
||||
async def generate_return_labels(
|
||||
request: GenerateReturnLabelsRequest,
|
||||
db: Session = Depends(get_db)
|
||||
) -> GenerateResponse:
|
||||
"""
|
||||
Generate and print return labels for the specified number of labels.
|
||||
|
||||
Args:
|
||||
request: Dictionary containing:
|
||||
- number_of_labels: Number of return labels to generate
|
||||
"""
|
||||
try:
|
||||
label_printer = service_manager.get_service('label_printer')
|
||||
success = await label_printer.print_file("app/data/assets/images/ccrcardsaddress.png", label_size="dk1201", label_type="return_label", copies=request.number_of_labels)
|
||||
return {"success": success, "message": "Return labels generated and printed successfully"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to generate return labels: {str(e)}")
|
||||
|
||||
@router.post("/print-pirate-ship-label")
|
||||
async def print_pirate_ship_label(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db)
|
||||
) -> GenerateResponse:
|
||||
"""
|
||||
Print a PDF file uploaded via the API.
|
||||
|
||||
Args:
|
||||
file: The PDF file to print
|
||||
|
||||
Returns:
|
||||
Success status of the operation
|
||||
"""
|
||||
try:
|
||||
# Read the file content
|
||||
content = await file.read()
|
||||
|
||||
# Store the file using FileService
|
||||
file_service = service_manager.get_service('file')
|
||||
stored_file = await file_service.save_file(
|
||||
db=db,
|
||||
file_data=content,
|
||||
filename=file.filename,
|
||||
subdir="pirate_ship_labels",
|
||||
file_type="pdf",
|
||||
content_type=file.content_type,
|
||||
metadata={"filename": file.filename}
|
||||
)
|
||||
|
||||
try:
|
||||
# Use the label printer service to print the file
|
||||
label_printer = service_manager.get_service('label_printer')
|
||||
success = await label_printer.print_file(stored_file, label_size="dk1241", label_type="pirate_ship_label")
|
||||
|
||||
return {"success": success, "message": "Pirate Ship label printed successfully"}
|
||||
except Exception as e:
|
||||
# If printing fails, we'll keep the file in storage for potential retry
|
||||
logger.error(f"Failed to print file: {str(e)}")
|
||||
raise e
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to print Pirate Ship label: {str(e)}")
|
||||
|
||||
# what even is this TODO delete
|
||||
@router.post("/process-manabox-csv")
|
||||
async def process_manabox_csv(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db)
|
||||
) -> GenerateResponse:
|
||||
try:
|
||||
# ensure csv
|
||||
if file.content_type != "text/csv":
|
||||
raise HTTPException(status_code=400, detail="File must be a CSV")
|
||||
# read file
|
||||
content = await file.read()
|
||||
# save file
|
||||
file_service = service_manager.get_service('file')
|
||||
stored_file = await file_service.save_file(db, content, f'manabox_upload_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv', "manabox_csvs", "csv")
|
||||
# process csv
|
||||
manabox_service = service_manager.get_service('manabox')
|
||||
success = await manabox_service.process_manabox_csv(db, stored_file)
|
||||
return {"success": success, "message": "Manabox CSV processed successfully"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to process Manabox CSV: {str(e)}")
|
||||
|
@ -3,14 +3,10 @@ from sqlalchemy.orm import Session
|
||||
from app.db.database import get_db
|
||||
from app.models.file import File as FileModel
|
||||
from app.schemas.file import FileCreate, FileUpdate, FileDelete, FileList, FileInDB
|
||||
from app.models.box import Box as BoxModel, OpenBox as OpenBoxModel
|
||||
from app.schemas.box import BoxCreate, BoxUpdate, BoxDelete, BoxList, OpenBoxCreate, OpenBoxUpdate, OpenBoxDelete, OpenBoxList, BoxInDB, OpenBoxInDB
|
||||
from app.models.game import Game as GameModel
|
||||
from app.schemas.game import GameCreate, GameUpdate, GameDelete, GameList, GameInDB
|
||||
from app.models.card import Card as CardModel
|
||||
from app.routes.set_label_routes import router as set_label_router
|
||||
from app.routes.order_routes import router as order_router
|
||||
|
||||
from app.routes.manabox_routes import router as manabox_router
|
||||
from app.routes.inventory_management_routes import router as inventory_management_router
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
# Include set label routes
|
||||
@ -19,6 +15,12 @@ router.include_router(set_label_router)
|
||||
# Include order routes
|
||||
router.include_router(order_router)
|
||||
|
||||
# Include manabox routes
|
||||
router.include_router(manabox_router)
|
||||
|
||||
# Include inventory management routes
|
||||
router.include_router(inventory_management_router)
|
||||
|
||||
# ============================================================================
|
||||
# Health Check & Root Endpoints
|
||||
# ============================================================================
|
||||
@ -47,62 +49,4 @@ async def update_file(file_id: int, file: FileUpdate):
|
||||
|
||||
@router.delete("/files/{file_id}", response_model=FileDelete)
|
||||
async def delete_file(file_id: int):
|
||||
return {"message": "File deleted successfully"}
|
||||
|
||||
# ============================================================================
|
||||
# Box Management Endpoints
|
||||
# ============================================================================
|
||||
@router.get("/boxes", response_model=BoxList)
|
||||
async def get_boxes(page: int = 1, limit: int = 10, type: str = None, id: int = None):
|
||||
return {"boxes": [], "total": 0, "page": page, "limit": limit}
|
||||
|
||||
@router.post("/boxes", response_model=BoxInDB)
|
||||
async def create_box(box: BoxCreate):
|
||||
return {"message": "Box created successfully"}
|
||||
|
||||
@router.put("/boxes/{box_id}", response_model=BoxInDB)
|
||||
async def update_box(box_id: int, box: BoxUpdate):
|
||||
return {"message": "Box updated successfully"}
|
||||
|
||||
@router.delete("/boxes/{box_id}", response_model=BoxDelete)
|
||||
async def delete_box(box_id: int):
|
||||
return {"message": "Box deleted successfully"}
|
||||
|
||||
# ============================================================================
|
||||
# Open Box Management Endpoints
|
||||
# ============================================================================
|
||||
@router.get("/open_boxes", response_model=OpenBoxList)
|
||||
async def get_open_boxes(page: int = 1, limit: int = 10, type: str = None, id: int = None):
|
||||
return {"open_boxes": [], "total": 0, "page": page, "limit": limit}
|
||||
|
||||
@router.post("/open_boxes", response_model=OpenBoxInDB)
|
||||
async def create_open_box(open_box: OpenBoxCreate):
|
||||
return {"message": "Open box created successfully"}
|
||||
|
||||
@router.put("/open_boxes/{open_box_id}", response_model=OpenBoxInDB)
|
||||
async def update_open_box(open_box_id: int, open_box: OpenBoxUpdate):
|
||||
return {"message": "Open box updated successfully"}
|
||||
|
||||
@router.delete("/open_boxes/{open_box_id}", response_model=OpenBoxDelete)
|
||||
async def delete_open_box(open_box_id: int):
|
||||
return {"message": "Open box deleted successfully"}
|
||||
|
||||
# ============================================================================
|
||||
# Game Management Endpoints
|
||||
# ============================================================================
|
||||
@router.get("/games", response_model=GameList)
|
||||
async def get_games(page: int = 1, limit: int = 10, type: str = None, id: int = None):
|
||||
return {"games": [], "total": 0, "page": page, "limit": limit}
|
||||
|
||||
@router.post("/games", response_model=GameInDB)
|
||||
async def create_game(game: GameCreate):
|
||||
return {"message": "Game created successfully"}
|
||||
|
||||
@router.put("/games/{game_id}", response_model=GameInDB)
|
||||
async def update_game(game_id: int, game: GameUpdate):
|
||||
return {"message": "Game updated successfully"}
|
||||
|
||||
@router.delete("/games/{game_id}", response_model=GameDelete)
|
||||
async def delete_game(game_id: int):
|
||||
return {"message": "Game deleted successfully"}
|
||||
|
||||
return {"message": "File deleted successfully"}
|
@ -1,72 +0,0 @@
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Base schema with common attributes
|
||||
class BoxBase(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
game_id: int
|
||||
set_id: Optional[int] = None
|
||||
price: Optional[float] = None
|
||||
quantity: Optional[int] = 0
|
||||
status: Optional[str] = "available" # available, sold, reserved
|
||||
|
||||
# Schema for creating a new box
|
||||
class BoxCreate(BoxBase):
|
||||
pass
|
||||
|
||||
# Schema for updating a box
|
||||
class BoxUpdate(BoxBase):
|
||||
pass
|
||||
|
||||
# Schema for reading a box
|
||||
class BoxInDB(BoxBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Schema for deleting a box
|
||||
class BoxDelete(BaseModel):
|
||||
message: str
|
||||
|
||||
# Schema for listing boxes
|
||||
class BoxList(BaseModel):
|
||||
boxes: List[BoxInDB]
|
||||
total: int
|
||||
page: int
|
||||
limit: int
|
||||
|
||||
# OpenBox schemas
|
||||
class OpenBoxBase(BaseModel):
|
||||
box_id: int
|
||||
opened_at: Optional[datetime] = None
|
||||
opened_by: Optional[str] = None
|
||||
contents: Optional[List[dict]] = None
|
||||
status: Optional[str] = "pending" # pending, opened, verified, listed
|
||||
|
||||
class OpenBoxCreate(OpenBoxBase):
|
||||
pass
|
||||
|
||||
class OpenBoxUpdate(OpenBoxBase):
|
||||
pass
|
||||
|
||||
class OpenBoxInDB(OpenBoxBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class OpenBoxDelete(BaseModel):
|
||||
message: str
|
||||
|
||||
class OpenBoxList(BaseModel):
|
||||
open_boxes: List[OpenBoxInDB]
|
||||
total: int
|
||||
page: int
|
||||
limit: int
|
@ -1,55 +0,0 @@
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Base schema with common attributes
|
||||
class CardBase(BaseModel):
|
||||
name: str
|
||||
rarity: Optional[str] = None
|
||||
set_name: Optional[str] = None
|
||||
price: Optional[float] = None
|
||||
quantity: Optional[int] = 0
|
||||
|
||||
# TCGPlayer specific fields
|
||||
tcgplayer_sku: Optional[str] = None
|
||||
product_line: Optional[str] = None
|
||||
product_name: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
number: Optional[str] = None
|
||||
condition: Optional[str] = None
|
||||
tcg_market_price: Optional[float] = None
|
||||
tcg_direct_low: Optional[float] = None
|
||||
tcg_low_price_with_shipping: Optional[float] = None
|
||||
tcg_low_price: Optional[float] = None
|
||||
total_quantity: Optional[int] = None
|
||||
add_to_quantity: Optional[int] = None
|
||||
tcg_marketplace_price: Optional[float] = None
|
||||
photo_url: Optional[str] = None
|
||||
|
||||
# Schema for creating a new card
|
||||
class CardCreate(CardBase):
|
||||
pass
|
||||
|
||||
# Schema for updating a card
|
||||
class CardUpdate(CardBase):
|
||||
pass
|
||||
|
||||
# Schema for reading a card (includes id and relationships)
|
||||
class CardInDB(CardBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Schema for listing cards
|
||||
class CardList(BaseModel):
|
||||
cards: List[CardInDB]
|
||||
total: int
|
||||
page: int
|
||||
limit: int
|
||||
|
||||
# Schema for deleting a card
|
||||
class CardDelete(BaseModel):
|
||||
message: str
|
@ -1,6 +1,7 @@
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
from fastapi import UploadFile
|
||||
|
||||
# Base schema with common attributes
|
||||
class FileBase(BaseModel):
|
||||
@ -37,4 +38,8 @@ class FileList(BaseModel):
|
||||
files: List[FileInDB]
|
||||
total: int
|
||||
page: int
|
||||
limit: int
|
||||
limit: int
|
||||
|
||||
# Schema for file upload
|
||||
class FileUpload(BaseModel):
|
||||
file: UploadFile
|
@ -1,41 +0,0 @@
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Base schema with common attributes
|
||||
class GameBase(BaseModel):
|
||||
name: str
|
||||
publisher: Optional[str] = None
|
||||
release_date: Optional[datetime] = None
|
||||
description: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
logo_url: Optional[str] = None
|
||||
status: Optional[str] = "active" # active, inactive, discontinued
|
||||
|
||||
# Schema for creating a new game
|
||||
class GameCreate(GameBase):
|
||||
pass
|
||||
|
||||
# Schema for updating a game
|
||||
class GameUpdate(GameBase):
|
||||
pass
|
||||
|
||||
# Schema for reading a game
|
||||
class GameInDB(GameBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Schema for deleting a game
|
||||
class GameDelete(BaseModel):
|
||||
message: str
|
||||
|
||||
# Schema for listing games
|
||||
class GameList(BaseModel):
|
||||
games: List[GameInDB]
|
||||
total: int
|
||||
page: int
|
||||
limit: int
|
@ -26,4 +26,7 @@ class GeneratePullSheetsRequest(GenerateRequest):
|
||||
|
||||
class GenerateResponse(BaseModel):
|
||||
message: str
|
||||
success: bool
|
||||
success: bool
|
||||
|
||||
class GenerateReturnLabelsRequest(BaseModel):
|
||||
number_of_labels: int
|
0
app/schemas/inventory.py
Normal file
0
app/schemas/inventory.py
Normal file
@ -76,6 +76,7 @@ class TCGPlayerAPIOrderSummary(BaseModel):
|
||||
orderStatus: str
|
||||
buyerName: str
|
||||
shippingType: str
|
||||
itemQuantity: int
|
||||
productAmount: float
|
||||
shippingAmount: float
|
||||
totalAmount: float
|
||||
|
109
app/schemas/transaction.py
Normal file
109
app/schemas/transaction.py
Normal file
@ -0,0 +1,109 @@
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from app.models.tcgplayer_products import TCGPlayerProduct
|
||||
class PurchaseItem(BaseModel):
|
||||
product_id: int
|
||||
unit_price: float
|
||||
quantity: int
|
||||
item_type: str
|
||||
num_boxes: Optional[int] = None
|
||||
# TODO: remove is_case and num_boxes, should derive from product_id
|
||||
|
||||
class SaleItem(BaseModel):
|
||||
inventory_item_id: int
|
||||
unit_price: float
|
||||
|
||||
class PurchaseTransactionCreate(BaseModel):
|
||||
vendor_id: int
|
||||
transaction_date: datetime
|
||||
items: List[PurchaseItem]
|
||||
transaction_notes: Optional[str] = None
|
||||
|
||||
class SaleTransactionCreate(BaseModel):
|
||||
customer_id: int
|
||||
marketplace_id: Optional[int] = None
|
||||
transaction_date: datetime
|
||||
items: List[SaleItem]
|
||||
transaction_notes: Optional[str] = None
|
||||
|
||||
class TransactionItemResponse(BaseModel):
|
||||
id: int
|
||||
transaction_id: int
|
||||
inventory_item_id: int
|
||||
unit_price: float
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: Optional[datetime] = None
|
||||
class TransactionResponse(BaseModel):
|
||||
id: int
|
||||
vendor_id: Optional[int] = None
|
||||
customer_id: Optional[int] = None
|
||||
marketplace_id: Optional[int] = None
|
||||
transaction_type: str
|
||||
transaction_date: datetime
|
||||
transaction_total_amount: float
|
||||
transaction_notes: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: Optional[datetime] = None
|
||||
transaction_items: List[TransactionItemResponse]
|
||||
|
||||
|
||||
class GetAllTransactionsResponse(BaseModel):
|
||||
total: int
|
||||
transactions: List[TransactionResponse]
|
||||
|
||||
class SealedExpectedValueResponse(BaseModel):
|
||||
id: int
|
||||
tcgplayer_product_id: int
|
||||
expected_value: float
|
||||
|
||||
class SealedExpectedValueCreate(BaseModel):
|
||||
tcgplayer_product_id: int
|
||||
expected_value: float
|
||||
|
||||
class TCGPlayerProductResponse(BaseModel):
|
||||
id: int
|
||||
tcgplayer_product_id: int
|
||||
name: str
|
||||
image_url: str
|
||||
category_id: int
|
||||
group_id: int
|
||||
url: str
|
||||
market_price: float
|
||||
category_name: str
|
||||
group_name: str
|
||||
|
||||
class InventoryItemResponse(BaseModel):
|
||||
id: int
|
||||
physical_item_id: int
|
||||
cost_basis: float
|
||||
item_type: str
|
||||
listed_price: Optional[float] = None
|
||||
marketplace_listing_id: Optional[int] = None
|
||||
recommended_price: Optional[float] = None
|
||||
parent_id: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
product: Optional[TCGPlayerProductResponse] = None
|
||||
|
||||
class OpenEventResponse(BaseModel):
|
||||
id: int
|
||||
source_item_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class OpenEventCreate(BaseModel):
|
||||
inventory_item_id: int
|
||||
manabox_file_upload_ids: List[int]
|
||||
|
||||
class OpenEventResultingItemsResponse(BaseModel):
|
||||
id: int
|
||||
source_item_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
resulting_items: List[InventoryItemResponse]
|
||||
|
||||
class OpenEventsForInventoryItemResponse(BaseModel):
|
||||
open_events: List[OpenEventResponse]
|
@ -3,11 +3,38 @@ from app.services.service_manager import ServiceManager
|
||||
from app.services.file_processing_service import FileProcessingService
|
||||
from app.services.inventory_service import InventoryService
|
||||
from app.services.file_service import FileService
|
||||
from app.services.data_initialization import DataInitializationService
|
||||
from app.services.external_api.tcgcsv.tcgcsv_service import TCGCSVService
|
||||
from app.services.external_api.mtgjson.mtgjson_service import MTGJSONService
|
||||
from app.services.label_printer_service import LabelPrinterService
|
||||
from app.services.regular_printer_service import RegularPrinterService
|
||||
from app.services.address_label_service import AddressLabelService
|
||||
from app.services.pull_sheet_service import PullSheetService
|
||||
from app.services.set_label_service import SetLabelService
|
||||
from app.services.scheduler.scheduler_service import SchedulerService
|
||||
from app.services.external_api.tcgplayer.order_management_service import OrderManagementService
|
||||
from app.services.external_api.tcgplayer.tcgplayer_inventory_service import TCGPlayerInventoryService
|
||||
from app.services.pricing_service import PricingService
|
||||
from app.services.inventory_service import MarketplaceListingService
|
||||
|
||||
__all__ = [
|
||||
'BaseService',
|
||||
'ServiceManager',
|
||||
'FileProcessingService',
|
||||
'InventoryService',
|
||||
'FileService'
|
||||
'FileService',
|
||||
'DataInitializationService',
|
||||
'TCGCSVService',
|
||||
'MTGJSONService',
|
||||
'LabelPrinterService',
|
||||
'RegularPrinterService',
|
||||
'AddressLabelService',
|
||||
'PullSheetService',
|
||||
'SetLabelService',
|
||||
'SchedulerService',
|
||||
'OrderManagementService',
|
||||
'TCGPlayerInventoryService',
|
||||
'PricingService',
|
||||
'MarketplaceListingService',
|
||||
'ScryfallService'
|
||||
]
|
File diff suppressed because it is too large
Load Diff
@ -92,24 +92,3 @@ class BaseExternalService:
|
||||
def file_service(self):
|
||||
"""Convenience property for file service"""
|
||||
return self.get_service('file')
|
||||
|
||||
async def save_file(self, db: Session, file_data: Union[bytes, list[dict]], file_name: str, subdir: str, file_type: Optional[str] = None) -> FileInDB:
|
||||
"""Save a file using the FileService"""
|
||||
if isinstance(file_data, list):
|
||||
# Convert list of dictionaries to CSV bytes
|
||||
output = io.StringIO()
|
||||
writer = csv.DictWriter(output, fieldnames=file_data[0].keys())
|
||||
writer.writeheader()
|
||||
writer.writerows(file_data)
|
||||
file_data = output.getvalue().encode('utf-8')
|
||||
file_type = file_type or 'text/csv'
|
||||
|
||||
# Use FileService to save the file
|
||||
file_service = self.get_service('file')
|
||||
return await file_service.save_file(
|
||||
db=db,
|
||||
file_data=file_data,
|
||||
filename=file_name,
|
||||
subdir=subdir,
|
||||
file_type=file_type
|
||||
)
|
@ -1,310 +1,137 @@
|
||||
import os
|
||||
import json
|
||||
import zipfile
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import time
|
||||
import sys
|
||||
from typing import Dict, Any, Optional, Generator
|
||||
import shutil
|
||||
from typing import Dict, Any, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from app.models.mtgjson_card import MTGJSONCard
|
||||
from app.models.mtgjson_sku import MTGJSONSKU
|
||||
from app.db.database import get_db, transaction
|
||||
from app.services.external_api.base_external_service import BaseExternalService
|
||||
from app.schemas.file import FileInDB
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MTGJSONService(BaseExternalService):
|
||||
def __init__(self, cache_dir: str = "app/data/cache/mtgjson", batch_size: int = 1000):
|
||||
def __init__(self):
|
||||
super().__init__(base_url="https://mtgjson.com/api/v5/")
|
||||
self.cache_dir = cache_dir
|
||||
self.identifiers_dir = os.path.join(cache_dir, "identifiers")
|
||||
self.skus_dir = os.path.join(cache_dir, "skus")
|
||||
self.batch_size = batch_size
|
||||
|
||||
# Create necessary directories
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
os.makedirs(self.identifiers_dir, exist_ok=True)
|
||||
os.makedirs(self.skus_dir, exist_ok=True)
|
||||
|
||||
def _format_progress(self, current: int, total: int, start_time: float) -> str:
|
||||
"""Format a progress message with percentage and timing information"""
|
||||
elapsed = time.time() - start_time
|
||||
if total > 0:
|
||||
percent = (current / total) * 100
|
||||
items_per_second = current / elapsed if elapsed > 0 else 0
|
||||
eta = (total - current) / items_per_second if items_per_second > 0 else 0
|
||||
return f"[{current}/{total} ({percent:.1f}%)] {items_per_second:.1f} items/sec, ETA: {eta:.1f}s"
|
||||
return f"[{current} items] {current/elapsed:.1f} items/sec"
|
||||
|
||||
def _print_progress(self, message: str, end: str = "\n") -> None:
|
||||
"""Print progress message with flush"""
|
||||
print(message, end=end, flush=True)
|
||||
|
||||
async def _download_file(self, db: Session, url: str, filename: str, subdir: str) -> FileInDB:
|
||||
async def _download_and_unzip_file(self, db: Session, url: str, filename: str, subdir: str) -> FileInDB:
|
||||
"""Download a file from the given URL and save it using FileService"""
|
||||
print(f"Downloading {url}...")
|
||||
start_time = time.time()
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as response:
|
||||
if response.status == 200:
|
||||
file_data = await response.read()
|
||||
return await self.save_file(
|
||||
# Use the base external service's _make_request method
|
||||
file_data = await self._make_request(
|
||||
method="GET",
|
||||
endpoint=url.replace(self.base_url, ""),
|
||||
binary=True
|
||||
)
|
||||
|
||||
# Save the file using the file service
|
||||
file_record = await self.file_service.save_file(
|
||||
db=db,
|
||||
file_data=file_data,
|
||||
filename=filename,
|
||||
subdir=f"mtgjson/{subdir}",
|
||||
file_type="application/zip",
|
||||
content_type="application/zip"
|
||||
)
|
||||
|
||||
# Unzip the file
|
||||
await self._unzip_file(file_record, subdir, db)
|
||||
|
||||
return file_record
|
||||
|
||||
|
||||
async def _unzip_file(self, file_record: FileInDB, subdir: str, db: Session) -> FileInDB:
|
||||
"""Unzip a file to the specified subdirectory and return the path to the extracted JSON file"""
|
||||
try:
|
||||
file_service = self.get_service('file')
|
||||
cache_dir = file_service.base_cache_dir
|
||||
temp_dir = os.path.join(cache_dir,'mtgjson', subdir, 'temp')
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
with zipfile.ZipFile(file_record.path, 'r') as zip_ref:
|
||||
json_filename = zip_ref.namelist()[0]
|
||||
zip_ref.extractall(temp_dir)
|
||||
json_path = os.path.join(temp_dir, json_filename)
|
||||
|
||||
# Create a file record for the extracted JSON file
|
||||
with open(json_path, 'r') as f:
|
||||
json_data = f.read()
|
||||
json_file_record = await self.file_service.save_file(
|
||||
db=db,
|
||||
file_data=file_data,
|
||||
file_name=filename,
|
||||
file_data=json_data,
|
||||
filename=json_filename,
|
||||
subdir=f"mtgjson/{subdir}",
|
||||
file_type=response.headers.get('content-type', 'application/octet-stream')
|
||||
file_type="application/json",
|
||||
content_type="application/json"
|
||||
)
|
||||
else:
|
||||
raise Exception(f"Failed to download file from {url}. Status: {response.status}")
|
||||
|
||||
async def _unzip_file(self, zip_path: str, extract_dir: str) -> str:
|
||||
"""Unzip a file to the specified directory and return the path to the extracted JSON file"""
|
||||
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||
json_filename = zip_ref.namelist()[0]
|
||||
zip_ref.extractall(extract_dir)
|
||||
return os.path.join(extract_dir, json_filename)
|
||||
|
||||
def _stream_json_file(self, file_path: str) -> Generator[Dict[str, Any], None, None]:
|
||||
"""Stream a JSON file and yield items one at a time"""
|
||||
print(f"Starting to stream JSON file: {file_path}")
|
||||
with open(file_path, 'r') as f:
|
||||
# Load the entire file since MTGJSON uses a specific format
|
||||
data = json.load(f)
|
||||
|
||||
# First yield the meta data
|
||||
if "meta" in data:
|
||||
yield {"type": "meta", "data": data["meta"]}
|
||||
|
||||
# Then yield each item in the data section
|
||||
if "data" in data:
|
||||
for key, value in data["data"].items():
|
||||
yield {"type": "item", "data": {key: value}}
|
||||
|
||||
async def _process_batch(self, db: Session, items: list, model_class) -> int:
|
||||
"""Process a batch of items and add them to the database"""
|
||||
processed = 0
|
||||
with transaction(db):
|
||||
for item in items:
|
||||
if model_class == MTGJSONCard:
|
||||
# Check if card already exists
|
||||
existing_card = db.query(MTGJSONCard).filter(MTGJSONCard.card_id == item["card_id"]).first()
|
||||
if existing_card:
|
||||
continue
|
||||
|
||||
new_item = MTGJSONCard(
|
||||
card_id=item["card_id"],
|
||||
name=item["name"],
|
||||
set_code=item["set_code"],
|
||||
uuid=item["uuid"],
|
||||
abu_id=item.get("abu_id"),
|
||||
card_kingdom_etched_id=item.get("card_kingdom_etched_id"),
|
||||
card_kingdom_foil_id=item.get("card_kingdom_foil_id"),
|
||||
card_kingdom_id=item.get("card_kingdom_id"),
|
||||
cardsphere_id=item.get("cardsphere_id"),
|
||||
cardsphere_foil_id=item.get("cardsphere_foil_id"),
|
||||
cardtrader_id=item.get("cardtrader_id"),
|
||||
csi_id=item.get("csi_id"),
|
||||
mcm_id=item.get("mcm_id"),
|
||||
mcm_meta_id=item.get("mcm_meta_id"),
|
||||
miniaturemarket_id=item.get("miniaturemarket_id"),
|
||||
mtg_arena_id=item.get("mtg_arena_id"),
|
||||
mtgjson_foil_version_id=item.get("mtgjson_foil_version_id"),
|
||||
mtgjson_non_foil_version_id=item.get("mtgjson_non_foil_version_id"),
|
||||
mtgjson_v4_id=item.get("mtgjson_v4_id"),
|
||||
mtgo_foil_id=item.get("mtgo_foil_id"),
|
||||
mtgo_id=item.get("mtgo_id"),
|
||||
multiverse_id=item.get("multiverse_id"),
|
||||
scg_id=item.get("scg_id"),
|
||||
scryfall_id=item.get("scryfall_id"),
|
||||
scryfall_card_back_id=item.get("scryfall_card_back_id"),
|
||||
scryfall_oracle_id=item.get("scryfall_oracle_id"),
|
||||
scryfall_illustration_id=item.get("scryfall_illustration_id"),
|
||||
tcgplayer_product_id=item.get("tcgplayer_product_id"),
|
||||
tcgplayer_etched_product_id=item.get("tcgplayer_etched_product_id"),
|
||||
tnt_id=item.get("tnt_id")
|
||||
)
|
||||
else: # MTGJSONSKU
|
||||
# Check if SKU already exists
|
||||
existing_sku = db.query(MTGJSONSKU).filter(MTGJSONSKU.sku_id == item["sku_id"]).first()
|
||||
if existing_sku:
|
||||
continue
|
||||
|
||||
new_item = MTGJSONSKU(
|
||||
sku_id=str(item["sku_id"]),
|
||||
product_id=str(item["product_id"]),
|
||||
condition=item["condition"],
|
||||
finish=item["finish"],
|
||||
language=item["language"],
|
||||
printing=item["printing"],
|
||||
card_id=item["card_id"]
|
||||
)
|
||||
db.add(new_item)
|
||||
processed += 1
|
||||
|
||||
return processed
|
||||
|
||||
async def download_and_process_identifiers(self, db: Session) -> Dict[str, int]:
|
||||
"""Download, unzip and process AllIdentifiers.json.zip using streaming"""
|
||||
self._print_progress("Starting MTGJSON identifiers processing...")
|
||||
start_time = time.time()
|
||||
|
||||
# Download the file using FileService
|
||||
file_record = await self._download_file(
|
||||
db=db,
|
||||
url="https://mtgjson.com/api/v5/AllIdentifiers.json.zip",
|
||||
filename="AllIdentifiers.json.zip",
|
||||
subdir="identifiers"
|
||||
)
|
||||
|
||||
# Get the file path from the database record
|
||||
zip_path = file_record.path
|
||||
|
||||
cards_processed = 0
|
||||
current_batch = []
|
||||
total_cards = 0
|
||||
last_progress_time = time.time()
|
||||
|
||||
self._print_progress("Processing cards...")
|
||||
try:
|
||||
for item in self._stream_json_file(zip_path):
|
||||
if item["type"] == "meta":
|
||||
self._print_progress(f"Processing MTGJSON data version {item['data'].get('version')} from {item['data'].get('date')}")
|
||||
continue
|
||||
|
||||
card_data = item["data"]
|
||||
card_id = list(card_data.keys())[0]
|
||||
card_info = card_data[card_id]
|
||||
total_cards += 1
|
||||
|
||||
current_batch.append({
|
||||
"card_id": card_id,
|
||||
"name": card_info.get("name"),
|
||||
"set_code": card_info.get("setCode"),
|
||||
"uuid": card_info.get("uuid"),
|
||||
"abu_id": card_info.get("identifiers", {}).get("abuId"),
|
||||
"card_kingdom_etched_id": card_info.get("identifiers", {}).get("cardKingdomEtchedId"),
|
||||
"card_kingdom_foil_id": card_info.get("identifiers", {}).get("cardKingdomFoilId"),
|
||||
"card_kingdom_id": card_info.get("identifiers", {}).get("cardKingdomId"),
|
||||
"cardsphere_id": card_info.get("identifiers", {}).get("cardsphereId"),
|
||||
"cardsphere_foil_id": card_info.get("identifiers", {}).get("cardsphereFoilId"),
|
||||
"cardtrader_id": card_info.get("identifiers", {}).get("cardtraderId"),
|
||||
"csi_id": card_info.get("identifiers", {}).get("csiId"),
|
||||
"mcm_id": card_info.get("identifiers", {}).get("mcmId"),
|
||||
"mcm_meta_id": card_info.get("identifiers", {}).get("mcmMetaId"),
|
||||
"miniaturemarket_id": card_info.get("identifiers", {}).get("miniaturemarketId"),
|
||||
"mtg_arena_id": card_info.get("identifiers", {}).get("mtgArenaId"),
|
||||
"mtgjson_foil_version_id": card_info.get("identifiers", {}).get("mtgjsonFoilVersionId"),
|
||||
"mtgjson_non_foil_version_id": card_info.get("identifiers", {}).get("mtgjsonNonFoilVersionId"),
|
||||
"mtgjson_v4_id": card_info.get("identifiers", {}).get("mtgjsonV4Id"),
|
||||
"mtgo_foil_id": card_info.get("identifiers", {}).get("mtgoFoilId"),
|
||||
"mtgo_id": card_info.get("identifiers", {}).get("mtgoId"),
|
||||
"multiverse_id": card_info.get("identifiers", {}).get("multiverseId"),
|
||||
"scg_id": card_info.get("identifiers", {}).get("scgId"),
|
||||
"scryfall_id": card_info.get("identifiers", {}).get("scryfallId"),
|
||||
"scryfall_card_back_id": card_info.get("identifiers", {}).get("scryfallCardBackId"),
|
||||
"scryfall_oracle_id": card_info.get("identifiers", {}).get("scryfallOracleId"),
|
||||
"scryfall_illustration_id": card_info.get("identifiers", {}).get("scryfallIllustrationId"),
|
||||
"tcgplayer_product_id": card_info.get("identifiers", {}).get("tcgplayerProductId"),
|
||||
"tcgplayer_etched_product_id": card_info.get("identifiers", {}).get("tcgplayerEtchedProductId"),
|
||||
"tnt_id": card_info.get("identifiers", {}).get("tntId"),
|
||||
"data": card_info
|
||||
})
|
||||
# remove the temp directory
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
if len(current_batch) >= self.batch_size:
|
||||
batch_processed = await self._process_batch(db, current_batch, MTGJSONCard)
|
||||
cards_processed += batch_processed
|
||||
current_batch = []
|
||||
current_time = time.time()
|
||||
if current_time - last_progress_time >= 1.0: # Update progress every second
|
||||
self._print_progress(f"\r{self._format_progress(cards_processed, total_cards, start_time)}", end="")
|
||||
last_progress_time = current_time
|
||||
return json_file_record
|
||||
except Exception as e:
|
||||
self._print_progress(f"\nError during processing: {str(e)}")
|
||||
logger.error(f"Error unzipping file: {e}")
|
||||
raise
|
||||
|
||||
# Process remaining items
|
||||
if current_batch:
|
||||
batch_processed = await self._process_batch(db, current_batch, MTGJSONCard)
|
||||
cards_processed += batch_processed
|
||||
|
||||
total_time = time.time() - start_time
|
||||
self._print_progress(f"\nProcessing complete! Processed {cards_processed} cards in {total_time:.1f} seconds")
|
||||
return {"cards_processed": cards_processed}
|
||||
|
||||
async def download_and_process_skus(self, db: Session) -> Dict[str, int]:
|
||||
"""Download, unzip and process TcgplayerSkus.json.zip using streaming"""
|
||||
self._print_progress("Starting MTGJSON SKUs processing...")
|
||||
start_time = time.time()
|
||||
|
||||
# Download the file using FileService
|
||||
file_record = await self._download_file(
|
||||
db=db,
|
||||
url="https://mtgjson.com/api/v5/TcgplayerSkus.json.zip",
|
||||
filename="TcgplayerSkus.json.zip",
|
||||
subdir="skus"
|
||||
)
|
||||
|
||||
# Get the file path from the database record
|
||||
zip_path = file_record.path
|
||||
|
||||
skus_processed = 0
|
||||
current_batch = []
|
||||
total_skus = 0
|
||||
last_progress_time = time.time()
|
||||
|
||||
self._print_progress("Processing SKUs...")
|
||||
try:
|
||||
for item in self._stream_json_file(zip_path):
|
||||
if item["type"] == "meta":
|
||||
self._print_progress(f"Processing MTGJSON SKUs version {item['data'].get('version')} from {item['data'].get('date')}")
|
||||
continue
|
||||
|
||||
# The data structure is {card_uuid: [sku1, sku2, ...]}
|
||||
for card_uuid, sku_list in item["data"].items():
|
||||
for sku in sku_list:
|
||||
total_skus += 1
|
||||
current_batch.append({
|
||||
"sku_id": str(sku.get("skuId")),
|
||||
"product_id": str(sku.get("productId")),
|
||||
"condition": sku.get("condition"),
|
||||
"finish": sku.get("finish"),
|
||||
"language": sku.get("language"),
|
||||
"printing": sku.get("printing"),
|
||||
"card_id": card_uuid,
|
||||
"data": sku
|
||||
})
|
||||
|
||||
if len(current_batch) >= self.batch_size:
|
||||
batch_processed = await self._process_batch(db, current_batch, MTGJSONSKU)
|
||||
skus_processed += batch_processed
|
||||
current_batch = []
|
||||
current_time = time.time()
|
||||
if current_time - last_progress_time >= 1.0: # Update progress every second
|
||||
self._print_progress(f"\r{self._format_progress(skus_processed, total_skus, start_time)}", end="")
|
||||
last_progress_time = current_time
|
||||
except Exception as e:
|
||||
self._print_progress(f"\nError during processing: {str(e)}")
|
||||
raise
|
||||
|
||||
# Process remaining items
|
||||
if current_batch:
|
||||
batch_processed = await self._process_batch(db, current_batch, MTGJSONSKU)
|
||||
skus_processed += batch_processed
|
||||
|
||||
total_time = time.time() - start_time
|
||||
self._print_progress(f"\nProcessing complete! Processed {skus_processed} SKUs in {total_time:.1f} seconds")
|
||||
return {"skus_processed": skus_processed}
|
||||
async def get_identifiers(self, db: Session, use_cache: bool = True) -> Dict[str, Any]:
|
||||
"""Download and get MTGJSON identifiers data"""
|
||||
# Check if we have a cached version
|
||||
cached_file = await self.file_service.get_file_by_filename(db, "AllIdentifiers.json")
|
||||
if cached_file and os.path.exists(cached_file.path) and use_cache:
|
||||
with open(cached_file.path, 'r') as f:
|
||||
logger.debug(f"Loaded identifiers from cache: {cached_file.path}")
|
||||
return json.load(f)
|
||||
else:
|
||||
# Download and process the file
|
||||
logger.debug(f"Downloading identifiers from MTGJSON")
|
||||
file_record = await self._download_and_unzip_file(
|
||||
db=db,
|
||||
url="https://mtgjson.com/api/v5/AllIdentifiers.json.zip",
|
||||
filename="AllIdentifiers.json.zip",
|
||||
subdir="identifiers"
|
||||
)
|
||||
|
||||
async def clear_cache(self) -> None:
|
||||
json_file = await self._unzip_file(file_record, "identifiers", db)
|
||||
|
||||
with open(json_file.path, 'r') as f:
|
||||
logger.debug(f"Loaded identifiers from MTGJSON: {json_file.path}")
|
||||
return json.load(f)
|
||||
|
||||
async def get_skus(self, db: Session, use_cache: bool = True) -> Dict[str, Any]:
|
||||
"""Download and get MTGJSON SKUs data"""
|
||||
# Check if we have a cached version
|
||||
cached_file = await self.file_service.get_file_by_filename(db, "TcgplayerSkus.json")
|
||||
if cached_file and os.path.exists(cached_file.path) and use_cache:
|
||||
with open(cached_file.path, 'r') as f:
|
||||
logger.debug(f"Loaded SKUs from cache: {cached_file.path}")
|
||||
return json.load(f)
|
||||
else:
|
||||
# Download and process the file
|
||||
logger.debug(f"Downloading SKUs from MTGJSON")
|
||||
file_record = await self._download_and_unzip_file(
|
||||
db=db,
|
||||
url="https://mtgjson.com/api/v5/TcgplayerSkus.json.zip",
|
||||
filename="TcgplayerSkus.json.zip",
|
||||
subdir="skus"
|
||||
)
|
||||
|
||||
json_file = await self._unzip_file(file_record, "skus", db)
|
||||
|
||||
with open(json_file.path, 'r') as f:
|
||||
logger.debug(f"Loaded SKUs from MTGJSON: {json_file.path}")
|
||||
return json.load(f)
|
||||
|
||||
async def clear_cache(self, db: Session) -> None:
|
||||
"""Clear all cached data"""
|
||||
for subdir in ["identifiers", "skus"]:
|
||||
dir_path = os.path.join(self.cache_dir, subdir)
|
||||
if os.path.exists(dir_path):
|
||||
for filename in os.listdir(dir_path):
|
||||
file_path = os.path.join(dir_path, filename)
|
||||
if os.path.isfile(file_path):
|
||||
os.unlink(file_path)
|
||||
print("MTGJSON cache cleared")
|
||||
try:
|
||||
# Delete all files in the mtgjson subdirectory
|
||||
files = await self.file_service.list_files(db, file_type=["json", "zip"])
|
||||
for file in files:
|
||||
if file.path.startswith("mtgjson/"):
|
||||
await self.file_service.delete_file(db, file.id)
|
||||
logger.info("MTGJSON cache cleared")
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing cache: {e}")
|
||||
raise
|
||||
|
11
app/services/external_api/scryfall/scryfall_service.py
Normal file
11
app/services/external_api/scryfall/scryfall_service.py
Normal file
@ -0,0 +1,11 @@
|
||||
from app.services.external_api.base_external_service import BaseExternalService
|
||||
|
||||
class ScryfallService(BaseExternalService):
|
||||
def __init__(self):
|
||||
super().__init__(base_url="https://api.scryfall.com/")
|
||||
|
||||
async def get_color_identity(self, scryfall_id: str) -> str:
|
||||
"""Get the color identity of a card from Scryfall API"""
|
||||
endpoint = f"cards/{scryfall_id}"
|
||||
results = await self._make_request("GET", endpoint)
|
||||
return results['color_identity']
|
@ -3,256 +3,49 @@ from datetime import datetime, timedelta
|
||||
import csv
|
||||
import io
|
||||
from app.services.external_api.base_external_service import BaseExternalService
|
||||
from app.models.tcgplayer_group import TCGPlayerGroup
|
||||
from app.models.tcgplayer_product import TCGPlayerProduct
|
||||
from app.models.tcgplayer_category import TCGPlayerCategory
|
||||
from app.db.database import get_db, transaction
|
||||
from sqlalchemy.orm import Session
|
||||
import py7zr
|
||||
import os
|
||||
from app.schemas.file import FileInDB
|
||||
|
||||
class TCGCSVService(BaseExternalService):
|
||||
def __init__(self):
|
||||
super().__init__(base_url="https://tcgcsv.com/")
|
||||
|
||||
async def get_groups(self, game_ids: List[int]) -> Dict[str, Any]:
|
||||
async def get_groups(self, game_id: int) -> Dict[str, Any]:
|
||||
"""Fetch groups for specific game IDs from TCGCSV API"""
|
||||
game_ids_str = ",".join(map(str, game_ids))
|
||||
endpoint = f"tcgplayer/{game_ids_str}/groups"
|
||||
endpoint = f"tcgplayer/{game_id}/groups"
|
||||
return await self._make_request("GET", endpoint)
|
||||
|
||||
async def get_products_and_prices(self, game_ids: List[int], group_id: int) -> List[Dict[str, Any]]:
|
||||
async def get_products_and_prices(self, game_id: str, group_id: int) -> str:
|
||||
"""Fetch products and prices for a specific group from TCGCSV API"""
|
||||
game_ids_str = ",".join(map(str, game_ids))
|
||||
endpoint = f"tcgplayer/{game_ids_str}/{group_id}/ProductsAndPrices.csv"
|
||||
response = await self._make_request("GET", endpoint, headers={"Accept": "text/csv"})
|
||||
|
||||
# Parse CSV response
|
||||
csv_data = io.StringIO(response)
|
||||
reader = csv.DictReader(csv_data)
|
||||
return list(reader)
|
||||
endpoint = f"tcgplayer/{game_id}/{group_id}/ProductsAndPrices.csv"
|
||||
return await self._make_request("GET", endpoint, headers={"Accept": "text/csv"})
|
||||
|
||||
async def get_categories(self) -> Dict[str, Any]:
|
||||
"""Fetch all categories from TCGCSV API"""
|
||||
endpoint = "tcgplayer/categories"
|
||||
return await self._make_request("GET", endpoint)
|
||||
|
||||
async def get_archived_prices_for_date(self, db: Session, date_str: str) -> str:
|
||||
async def get_archived_prices_for_date(self, date_str: str) -> bytes:
|
||||
"""Fetch archived prices from TCGCSV API"""
|
||||
# Download the archive file
|
||||
endpoint = f"archive/tcgplayer/prices-{date_str}.ppmd.7z"
|
||||
response = await self._make_request("GET", endpoint, binary=True)
|
||||
|
||||
# Save the archive file using FileService
|
||||
file_record = await self.save_file(
|
||||
db=db,
|
||||
file_data=response,
|
||||
file_name=f"prices-{date_str}.ppmd.7z",
|
||||
subdir=f"tcgcsv/prices/zip",
|
||||
file_type="application/x-7z-compressed"
|
||||
)
|
||||
|
||||
# Extract the 7z file
|
||||
with py7zr.SevenZipFile(file_record.path, 'r') as archive:
|
||||
# Extract to a directory named after the date
|
||||
extract_path = f"app/data/cache/tcgcsv/prices/{date_str}"
|
||||
os.makedirs(extract_path, exist_ok=True)
|
||||
archive.extractall(path=extract_path)
|
||||
|
||||
return date_str
|
||||
return await self._make_request("GET", endpoint, binary=True)
|
||||
|
||||
async def get_archived_prices_for_date_range(self, start_date: str, end_date: str):
|
||||
"""Fetch archived prices for a date range from TCGCSV API"""
|
||||
# Convert string dates to datetime objects
|
||||
async def get_tcgcsv_date_range(self, start_date: datetime, end_date: datetime) -> List[datetime]:
|
||||
"""Get a date range for a given start and end date"""
|
||||
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
||||
|
||||
# Set minimum start date
|
||||
min_start_date = datetime.strptime("2025-02-08", "%Y-%m-%d")
|
||||
min_start_date = datetime.strptime("2024-02-08", "%Y-%m-%d")
|
||||
max_end_date = datetime.now()
|
||||
if start_dt < min_start_date:
|
||||
start_dt = min_start_date
|
||||
|
||||
# Set maximum end date to today
|
||||
today = datetime.now()
|
||||
if end_dt > today:
|
||||
end_dt = today
|
||||
|
||||
# Generate date range
|
||||
if end_dt > max_end_date:
|
||||
end_dt = max_end_date
|
||||
date_range = []
|
||||
current_dt = start_dt
|
||||
while current_dt <= end_dt:
|
||||
date_range.append(current_dt.strftime("%Y-%m-%d"))
|
||||
current_dt += timedelta(days=1)
|
||||
|
||||
# Process each date
|
||||
for date_str in date_range:
|
||||
await self.get_archived_prices_for_date(date_str)
|
||||
|
||||
async def sync_groups_to_db(self, db: Session, game_ids: List[int]) -> List[TCGPlayerGroup]:
|
||||
"""Fetch groups from API and sync them to the database"""
|
||||
response = await self.get_groups(game_ids)
|
||||
|
||||
if not response.get("success"):
|
||||
raise Exception(f"Failed to fetch groups: {response.get('errors')}")
|
||||
|
||||
groups = response.get("results", [])
|
||||
synced_groups = []
|
||||
with transaction(db):
|
||||
for group_data in groups:
|
||||
# Convert string dates to datetime objects
|
||||
published_on = datetime.fromisoformat(group_data["publishedOn"].replace("Z", "+00:00")) if group_data.get("publishedOn") else None
|
||||
modified_on = datetime.fromisoformat(group_data["modifiedOn"].replace("Z", "+00:00")) if group_data.get("modifiedOn") else None
|
||||
|
||||
# Check if group already exists
|
||||
existing_group = db.query(TCGPlayerGroup).filter(TCGPlayerGroup.group_id == group_data["groupId"]).first()
|
||||
|
||||
if existing_group:
|
||||
# Update existing group
|
||||
for key, value in {
|
||||
"name": group_data["name"],
|
||||
"abbreviation": group_data.get("abbreviation"),
|
||||
"is_supplemental": group_data.get("isSupplemental", False),
|
||||
"published_on": published_on,
|
||||
"modified_on": modified_on,
|
||||
"category_id": group_data.get("categoryId")
|
||||
}.items():
|
||||
setattr(existing_group, key, value)
|
||||
synced_groups.append(existing_group)
|
||||
else:
|
||||
# Create new group
|
||||
new_group = TCGPlayerGroup(
|
||||
group_id=group_data["groupId"],
|
||||
name=group_data["name"],
|
||||
abbreviation=group_data.get("abbreviation"),
|
||||
is_supplemental=group_data.get("isSupplemental", False),
|
||||
published_on=published_on,
|
||||
modified_on=modified_on,
|
||||
category_id=group_data.get("categoryId")
|
||||
)
|
||||
db.add(new_group)
|
||||
synced_groups.append(new_group)
|
||||
|
||||
return synced_groups
|
||||
|
||||
async def sync_products_to_db(self, db: Session, game_id: int, group_id: int) -> List[TCGPlayerProduct]:
|
||||
"""Fetch products and prices for a group and sync them to the database"""
|
||||
products_data = await self.get_products_and_prices(game_id, group_id)
|
||||
synced_products = []
|
||||
|
||||
for product_data in products_data:
|
||||
# Convert string dates to datetime objects
|
||||
modified_on = datetime.fromisoformat(product_data["modifiedOn"].replace("Z", "+00:00")) if product_data.get("modifiedOn") else None
|
||||
|
||||
# Convert price strings to floats, handling empty strings
|
||||
def parse_price(price_str):
|
||||
return float(price_str) if price_str else None
|
||||
|
||||
# Check if product already exists
|
||||
existing_product = db.query(TCGPlayerProduct).filter(TCGPlayerProduct.product_id == int(product_data["productId"])).first()
|
||||
|
||||
if existing_product:
|
||||
# Update existing product
|
||||
for key, value in {
|
||||
"name": product_data["name"],
|
||||
"clean_name": product_data.get("cleanName"),
|
||||
"image_url": product_data.get("imageUrl"),
|
||||
"category_id": int(product_data["categoryId"]),
|
||||
"group_id": int(product_data["groupId"]),
|
||||
"url": product_data.get("url"),
|
||||
"modified_on": modified_on,
|
||||
"image_count": int(product_data.get("imageCount", 0)),
|
||||
"ext_rarity": product_data.get("extRarity"),
|
||||
"ext_number": product_data.get("extNumber"),
|
||||
"low_price": parse_price(product_data.get("lowPrice")),
|
||||
"mid_price": parse_price(product_data.get("midPrice")),
|
||||
"high_price": parse_price(product_data.get("highPrice")),
|
||||
"market_price": parse_price(product_data.get("marketPrice")),
|
||||
"direct_low_price": parse_price(product_data.get("directLowPrice")),
|
||||
"sub_type_name": product_data.get("subTypeName")
|
||||
}.items():
|
||||
setattr(existing_product, key, value)
|
||||
synced_products.append(existing_product)
|
||||
else:
|
||||
# Create new product
|
||||
with transaction(db):
|
||||
new_product = TCGPlayerProduct(
|
||||
product_id=int(product_data["productId"]),
|
||||
name=product_data["name"],
|
||||
clean_name=product_data.get("cleanName"),
|
||||
image_url=product_data.get("imageUrl"),
|
||||
category_id=int(product_data["categoryId"]),
|
||||
group_id=int(product_data["groupId"]),
|
||||
url=product_data.get("url"),
|
||||
modified_on=modified_on,
|
||||
image_count=int(product_data.get("imageCount", 0)),
|
||||
ext_rarity=product_data.get("extRarity"),
|
||||
ext_number=product_data.get("extNumber"),
|
||||
low_price=parse_price(product_data.get("lowPrice")),
|
||||
mid_price=parse_price(product_data.get("midPrice")),
|
||||
high_price=parse_price(product_data.get("highPrice")),
|
||||
market_price=parse_price(product_data.get("marketPrice")),
|
||||
direct_low_price=parse_price(product_data.get("directLowPrice")),
|
||||
sub_type_name=product_data.get("subTypeName")
|
||||
)
|
||||
db.add(new_product)
|
||||
synced_products.append(new_product)
|
||||
|
||||
return synced_products
|
||||
|
||||
async def sync_categories_to_db(self, db: Session) -> List[TCGPlayerCategory]:
|
||||
"""Fetch categories from API and sync them to the database"""
|
||||
response = await self.get_categories()
|
||||
|
||||
if not response.get("success"):
|
||||
raise Exception(f"Failed to fetch categories: {response.get('errors')}")
|
||||
|
||||
categories = response.get("results", [])
|
||||
synced_categories = []
|
||||
with transaction(db):
|
||||
for category_data in categories:
|
||||
# Convert string dates to datetime objects
|
||||
modified_on = datetime.fromisoformat(category_data["modifiedOn"].replace("Z", "+00:00")) if category_data.get("modifiedOn") else None
|
||||
|
||||
# Check if category already exists
|
||||
existing_category = db.query(TCGPlayerCategory).filter(TCGPlayerCategory.category_id == category_data["categoryId"]).first()
|
||||
|
||||
if existing_category:
|
||||
# Update existing category
|
||||
for key, value in {
|
||||
"name": category_data["name"],
|
||||
"display_name": category_data.get("displayName"),
|
||||
"seo_category_name": category_data.get("seoCategoryName"),
|
||||
"category_description": category_data.get("categoryDescription"),
|
||||
"category_page_title": category_data.get("categoryPageTitle"),
|
||||
"sealed_label": category_data.get("sealedLabel"),
|
||||
"non_sealed_label": category_data.get("nonSealedLabel"),
|
||||
"condition_guide_url": category_data.get("conditionGuideUrl"),
|
||||
"is_scannable": category_data.get("isScannable", False),
|
||||
"popularity": category_data.get("popularity", 0),
|
||||
"is_direct": category_data.get("isDirect", False),
|
||||
"modified_on": modified_on
|
||||
}.items():
|
||||
setattr(existing_category, key, value)
|
||||
synced_categories.append(existing_category)
|
||||
else:
|
||||
# Create new category
|
||||
new_category = TCGPlayerCategory(
|
||||
category_id=category_data["categoryId"],
|
||||
name=category_data["name"],
|
||||
display_name=category_data.get("displayName"),
|
||||
seo_category_name=category_data.get("seoCategoryName"),
|
||||
category_description=category_data.get("categoryDescription"),
|
||||
category_page_title=category_data.get("categoryPageTitle"),
|
||||
sealed_label=category_data.get("sealedLabel"),
|
||||
non_sealed_label=category_data.get("nonSealedLabel"),
|
||||
condition_guide_url=category_data.get("conditionGuideUrl"),
|
||||
is_scannable=category_data.get("isScannable", False),
|
||||
popularity=category_data.get("popularity", 0),
|
||||
is_direct=category_data.get("isDirect", False),
|
||||
modified_on=modified_on
|
||||
)
|
||||
db.add(new_category)
|
||||
synced_categories.append(new_category)
|
||||
|
||||
return synced_categories
|
||||
return date_range
|
||||
|
||||
async def get_archived_prices_for_date_range(self, start_date: datetime, end_date: datetime) -> List[datetime]:
|
||||
"""Fetch archived prices for a date range from TCGCSV API"""
|
||||
date_range = await self.get_tcgcsv_date_range(start_date, end_date)
|
||||
return date_range
|
||||
|
@ -16,6 +16,7 @@ from app.models.tcgplayer_order import (
|
||||
TCGPlayerOrderProduct,
|
||||
TCGPlayerOrderRefund
|
||||
)
|
||||
from app.models.tcgplayer_products import TCGPlayerProduct
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import transaction
|
||||
import os
|
||||
@ -23,6 +24,7 @@ import csv
|
||||
import io
|
||||
from app.schemas.file import FileInDB
|
||||
from datetime import datetime
|
||||
from sqlalchemy import func
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class OrderManagementService(BaseTCGPlayerService):
|
||||
@ -39,7 +41,34 @@ class OrderManagementService(BaseTCGPlayerService):
|
||||
self.pull_sheet_endpoint = f"/pull-sheets/export{self.API_VERSION}"
|
||||
self.shipping_endpoint = f"/shipping/export{self.API_VERSION}"
|
||||
|
||||
|
||||
async def add_item_quantity(self, db: Session, orders: list[TCGPlayerAPIOrderSummary]) -> list[TCGPlayerAPIOrderSummary]:
|
||||
"""
|
||||
Add item quantity to orders using SQL aggregation for better performance
|
||||
"""
|
||||
# Get order numbers from the input orders
|
||||
order_numbers = [order["orderNumber"] for order in orders]
|
||||
|
||||
# Use SQL aggregation to get the sum of quantities directly from the database
|
||||
quantity_sums = (
|
||||
db.query(
|
||||
TCGPlayerOrderProduct.order_number,
|
||||
func.sum(TCGPlayerOrderProduct.quantity).label('total_quantity')
|
||||
)
|
||||
.filter(TCGPlayerOrderProduct.order_number.in_(order_numbers))
|
||||
.group_by(TCGPlayerOrderProduct.order_number)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Create a lookup dictionary for faster access
|
||||
quantity_lookup = {order_number: total_quantity for order_number, total_quantity in quantity_sums}
|
||||
|
||||
# Update orders with quantities
|
||||
for order in orders:
|
||||
order["itemQuantity"] = quantity_lookup.get(order["orderNumber"], 0)
|
||||
|
||||
return orders
|
||||
|
||||
|
||||
async def get_orders(self, search_range: str = "LastThreeMonths", open_only: bool = False, filter_out: list[str] = [], filter_in: list[str] = []) -> list[TCGPlayerAPIOrderSummary]:
|
||||
"""
|
||||
search range options:
|
||||
@ -78,6 +107,9 @@ class OrderManagementService(BaseTCGPlayerService):
|
||||
orders = [order for order in orders if order.get("orderNumber") not in filter_out]
|
||||
if filter_in:
|
||||
orders = [order for order in orders if order.get("orderNumber") in filter_in]
|
||||
# add item quantity to orders as none
|
||||
for order in orders:
|
||||
order["itemQuantity"] = 0
|
||||
return orders
|
||||
|
||||
async def get_order_ids(self, search_range: str = "LastThreeMonths", open_only: bool = False, filter_out: list[str] = [], filter_in: list[str] = []):
|
||||
@ -190,8 +222,7 @@ class OrderManagementService(BaseTCGPlayerService):
|
||||
direct_fee_amount=api_order.transaction.directFeeAmount,
|
||||
taxes=[{"code": t.code, "amount": t.amount} for t in api_order.transaction.taxes]
|
||||
)
|
||||
|
||||
# Create products
|
||||
# Create products
|
||||
db_products = [
|
||||
TCGPlayerOrderProductCreate(
|
||||
order_number=api_order.orderNumber,
|
||||
@ -376,8 +407,8 @@ class OrderManagementService(BaseTCGPlayerService):
|
||||
('extended_price', 'extendedPrice'),
|
||||
('quantity', 'quantity'),
|
||||
('url', 'url'),
|
||||
('product_id', 'productId'),
|
||||
('sku_id', 'skuId')
|
||||
('tcgplayer_product_id', 'productId'),
|
||||
('tcgplayer_sku_id', 'skuId')
|
||||
]
|
||||
|
||||
for db_field, api_field in product_fields_to_compare:
|
||||
|
@ -2,6 +2,11 @@ from typing import Dict, List, Optional
|
||||
from app.services.external_api.tcgplayer.base_tcgplayer_service import BaseTCGPlayerService
|
||||
from sqlalchemy.orm import Session
|
||||
from app.schemas.file import FileInDB
|
||||
from app.models.tcgplayer_inventory import TCGPlayerInventory, UnmanagedTCGPlayerInventory
|
||||
import csv
|
||||
from app.db.database import transaction
|
||||
from app.models.inventory_management import MarketplaceListing, InventoryItem, Marketplace
|
||||
from sqlalchemy import func
|
||||
|
||||
class TCGPlayerInventoryService(BaseTCGPlayerService):
|
||||
def __init__(self):
|
||||
@ -24,10 +29,46 @@ class TCGPlayerInventoryService(BaseTCGPlayerService):
|
||||
raise ValueError(f"Invalid export type: {export_type}, must be 'staged', 'live', or 'pricing'")
|
||||
|
||||
file_bytes = await self._make_request("GET", endpoint, download_file=True)
|
||||
return await self.save_file(
|
||||
return await self.file_service.save_file(
|
||||
db=db,
|
||||
file_data=file_bytes,
|
||||
file_name=f"tcgplayer_{export_type}_export.csv",
|
||||
filename=f"tcgplayer_{export_type}_export.csv",
|
||||
subdir="tcgplayer/inventory",
|
||||
file_type=file_type
|
||||
)
|
||||
)
|
||||
|
||||
async def refresh_tcgplayer_inventory_table(self, db: Session):
|
||||
"""
|
||||
Refresh the TCGPlayer inventory table
|
||||
"""
|
||||
export = await self.get_tcgplayer_export(db, "live")
|
||||
csv_string = await self.file_service.file_in_db_to_csv(db, export)
|
||||
reader = csv.DictReader(csv_string.splitlines())
|
||||
|
||||
# Convert CSV rows to list of dictionaries for bulk insert
|
||||
inventory_data = []
|
||||
for row in reader:
|
||||
if row.get("TCGplayer Id") is None:
|
||||
continue
|
||||
inventory_data.append({
|
||||
"tcgplayer_sku_id": int(row.get("TCGplayer Id")),
|
||||
"product_line": row.get("Product Line") if row.get("Product Line") else None,
|
||||
"set_name": row.get("Set Name") if row.get("Set Name") else None,
|
||||
"product_name": row.get("Product Name") if row.get("Product Name") else None,
|
||||
"title": row.get("Title") if row.get("Title") else None,
|
||||
"number": row.get("Number") if row.get("Number") else None,
|
||||
"rarity": row.get("Rarity") if row.get("Rarity") else None,
|
||||
"condition": row.get("Condition") if row.get("Condition") else None,
|
||||
"tcg_market_price": float(row.get("TCG Market Price")) if row.get("TCG Market Price") else None,
|
||||
"tcg_direct_low": float(row.get("TCG Direct Low")) if row.get("TCG Direct Low") else None,
|
||||
"tcg_low_price_with_shipping": float(row.get("TCG Low Price With Shipping")) if row.get("TCG Low Price With Shipping") else None,
|
||||
"tcg_low_price": float(row.get("TCG Low Price")) if row.get("TCG Low Price") else None,
|
||||
"total_quantity": int(row.get("Total Quantity")) if row.get("Total Quantity") else None,
|
||||
"add_to_quantity": int(row.get("Add to Quantity")) if row.get("Add to Quantity") else None,
|
||||
"tcg_marketplace_price": float(row.get("TCG Marketplace Price")) if row.get("TCG Marketplace Price") else None,
|
||||
"photo_url": row.get("Photo URL") if row.get("Photo URL") else None
|
||||
})
|
||||
|
||||
with transaction(db):
|
||||
# Bulk insert new data
|
||||
db.bulk_insert_mappings(TCGPlayerInventory, inventory_data)
|
@ -6,8 +6,6 @@ import json
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import transaction
|
||||
from app.models.inventory import Inventory
|
||||
from app.models.tcgplayer_product import TCGPlayerProduct
|
||||
from app.services.inventory_service import InventoryService
|
||||
|
||||
class FileProcessingService:
|
||||
|
@ -120,7 +120,7 @@ class FileService:
|
||||
"""List files with optional filtering"""
|
||||
query = db.query(File)
|
||||
if file_type:
|
||||
query = query.filter(File.type == file_type)
|
||||
query = query.filter(File.file_type == file_type).filter(File.deleted_at == None).order_by(File.created_at.desc())
|
||||
files = query.offset(skip).limit(limit).all()
|
||||
return [FileInDB.model_validate(file) for file in files]
|
||||
|
||||
@ -150,3 +150,16 @@ class FileService:
|
||||
return FileInDB.model_validate(file_record)
|
||||
else:
|
||||
return None
|
||||
|
||||
async def get_file_by_filename(self, db: Session, filename: str) -> Optional[FileInDB]:
|
||||
"""Get a file record from the database by filename"""
|
||||
# get most recent file by filename
|
||||
file_record = db.query(File).filter(File.name == filename).order_by(File.created_at.desc()).first()
|
||||
if file_record:
|
||||
return FileInDB.model_validate(file_record)
|
||||
return None
|
||||
|
||||
async def file_in_db_to_csv(self, db: Session, file: FileInDB) -> str:
|
||||
"""Convert a file in the database to a CSV string"""
|
||||
with open(file.path, "r") as f:
|
||||
return f.read()
|
||||
|
@ -1,63 +1,565 @@
|
||||
from typing import List, Optional, Dict
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.inventory import Inventory
|
||||
from typing import List, Optional, Dict, TypedDict
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from decimal import Decimal
|
||||
from app.services.base_service import BaseService
|
||||
from app.models.manabox_import_staging import ManaboxImportStaging
|
||||
from app.contexts.inventory_item import InventoryItemContextFactory
|
||||
from app.models.inventory_management import (
|
||||
OpenEvent, Card, InventoryItem, Case, SealedExpectedValue,
|
||||
Transaction, TransactionItem, Customer, Vendor, Marketplace, Box, MarketplaceListing
|
||||
)
|
||||
from app.schemas.file import FileInDB
|
||||
from app.models.inventory_management import PhysicalItem
|
||||
from app.schemas.transaction import PurchaseTransactionCreate, SaleTransactionCreate, TransactionResponse, SealedExpectedValueCreate
|
||||
from app.db.database import transaction as db_transaction
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
import logging
|
||||
|
||||
class InventoryService(BaseService[Inventory]):
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class InventoryService(BaseService):
|
||||
def __init__(self):
|
||||
super().__init__(Inventory)
|
||||
super().__init__(None)
|
||||
|
||||
def create(self, db: Session, obj_in: Dict) -> Inventory:
|
||||
"""
|
||||
Create a new inventory item in the database.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
obj_in: Dictionary containing inventory data
|
||||
|
||||
Returns:
|
||||
Inventory: The created inventory object
|
||||
"""
|
||||
return super().create(db, obj_in)
|
||||
async def get_resulting_items_for_open_event(self, db: Session, open_event: OpenEvent) -> List[InventoryItem]:
|
||||
# Get the IDs of resulting items
|
||||
resulting_item_ids = [item.id for item in open_event.resulting_items]
|
||||
# Query using the IDs
|
||||
return db.query(InventoryItem).filter(InventoryItem.physical_item_id.in_(resulting_item_ids)).filter(InventoryItem.deleted_at == None).all()
|
||||
|
||||
async def get_open_event(self, db: Session, inventory_item: InventoryItem, open_event_id: int) -> OpenEvent:
|
||||
return db.query(OpenEvent).filter(OpenEvent.source_item == inventory_item.physical_item).filter(OpenEvent.id == open_event_id).filter(OpenEvent.deleted_at == None).first()
|
||||
|
||||
async def get_open_events_for_inventory_item(self, db: Session, inventory_item: InventoryItem) -> List[OpenEvent]:
|
||||
return db.query(OpenEvent).filter(OpenEvent.source_item == inventory_item.physical_item).filter(OpenEvent.deleted_at == None).all()
|
||||
|
||||
def update(self, db: Session, db_obj: Inventory, obj_in: Dict) -> Inventory:
|
||||
"""
|
||||
Update an existing inventory item in the database.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
db_obj: The inventory object to update
|
||||
obj_in: Dictionary containing updated inventory data
|
||||
|
||||
Returns:
|
||||
Inventory: The updated inventory object
|
||||
"""
|
||||
return super().update(db, db_obj, obj_in)
|
||||
async def get_inventory_item(self, db: Session, inventory_item_id: int) -> InventoryItem:
|
||||
return db.query(InventoryItem)\
|
||||
.options(
|
||||
joinedload(InventoryItem.physical_item).joinedload(PhysicalItem.product_direct)
|
||||
)\
|
||||
.filter(InventoryItem.id == inventory_item_id)\
|
||||
.first()
|
||||
|
||||
def get_by_tcgplayer_id(self, db: Session, tcgplayer_id: str) -> Optional[Inventory]:
|
||||
"""
|
||||
Get an inventory item by its TCGPlayer ID.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tcgplayer_id: The TCGPlayer ID to find
|
||||
|
||||
Returns:
|
||||
Optional[Inventory]: The inventory item if found, None otherwise
|
||||
"""
|
||||
return db.query(self.model).filter(self.model.tcgplayer_id == tcgplayer_id).first()
|
||||
async def get_expected_value(self, db: Session, product_id: int) -> float:
|
||||
expected_value = db.query(SealedExpectedValue).filter(SealedExpectedValue.tcgplayer_product_id == product_id).filter(SealedExpectedValue.deleted_at == None).first()
|
||||
return expected_value.expected_value if expected_value else None
|
||||
|
||||
async def get_transactions(self, db: Session, skip: int, limit: int) -> List[Transaction]:
|
||||
return db.query(Transaction)\
|
||||
.filter(Transaction.deleted_at == None)\
|
||||
.order_by(Transaction.transaction_date.desc())\
|
||||
.offset(skip)\
|
||||
.limit(limit)\
|
||||
.all()
|
||||
|
||||
async def get_transaction(self, db: Session, transaction_id: int) -> Transaction:
|
||||
return db.query(Transaction)\
|
||||
.options(
|
||||
joinedload(Transaction.transaction_items).joinedload(TransactionItem.inventory_item).joinedload(InventoryItem.physical_item).joinedload(PhysicalItem.product_direct),
|
||||
joinedload(Transaction.vendors),
|
||||
joinedload(Transaction.customers),
|
||||
joinedload(Transaction.marketplaces)
|
||||
)\
|
||||
.filter(Transaction.id == transaction_id)\
|
||||
.filter(Transaction.deleted_at == None)\
|
||||
.first()
|
||||
|
||||
async def create_expected_value(self, db: Session, expected_value_data: SealedExpectedValueCreate) -> SealedExpectedValue:
|
||||
with db_transaction(db):
|
||||
expected_value = SealedExpectedValue(
|
||||
tcgplayer_product_id=expected_value_data.tcgplayer_product_id,
|
||||
expected_value=expected_value_data.expected_value
|
||||
)
|
||||
db.add(expected_value)
|
||||
db.flush()
|
||||
return expected_value
|
||||
|
||||
def get_by_set(self, db: Session, set_name: str, skip: int = 0, limit: int = 100) -> List[Inventory]:
|
||||
async def create_purchase_transaction(
|
||||
self,
|
||||
db: Session,
|
||||
transaction_data: PurchaseTransactionCreate
|
||||
) -> Transaction:
|
||||
"""
|
||||
Get all inventory items from a specific set.
|
||||
Creates a purchase transaction from a vendor.
|
||||
For each item:
|
||||
1. Creates a PhysicalItem (SealedCase/SealedBox)
|
||||
2. Creates an InventoryItem with the purchase price as cost basis
|
||||
3. Creates TransactionItems linking the purchase to the items
|
||||
"""
|
||||
try:
|
||||
with db_transaction(db):
|
||||
# Create the transaction
|
||||
transaction = Transaction(
|
||||
vendor_id=transaction_data.vendor_id,
|
||||
transaction_type='purchase',
|
||||
transaction_date=transaction_data.transaction_date,
|
||||
transaction_notes=transaction_data.transaction_notes
|
||||
)
|
||||
db.add(transaction)
|
||||
db.flush()
|
||||
|
||||
total_amount = 0
|
||||
physical_items = []
|
||||
case_service = self.get_service("case")
|
||||
box_service = self.get_service("box")
|
||||
for item in transaction_data.items:
|
||||
# Create the physical item based on type
|
||||
# TODO: remove is_case and num_boxes, should derive from product_id
|
||||
# TODO: add support for purchasing single cards
|
||||
if item.item_type == "case":
|
||||
for i in range(item.quantity):
|
||||
physical_item = await case_service.create_case(
|
||||
db=db,
|
||||
product_id=item.product_id,
|
||||
cost_basis=item.unit_price,
|
||||
num_boxes=item.num_boxes
|
||||
)
|
||||
physical_items.append(physical_item)
|
||||
elif item.item_type == "box":
|
||||
for i in range(item.quantity):
|
||||
physical_item = await box_service.create_box(
|
||||
db=db,
|
||||
product_id=item.product_id,
|
||||
cost_basis=item.unit_price
|
||||
)
|
||||
physical_items.append(physical_item)
|
||||
else:
|
||||
raise ValueError(f"Invalid item type: {item.item_type}")
|
||||
# TODO: add support for purchasing single cards
|
||||
|
||||
for physical_item in physical_items:
|
||||
# Create transaction item
|
||||
transaction.transaction_items.append(TransactionItem(
|
||||
inventory_item_id=physical_item.inventory_item.id,
|
||||
unit_price=item.unit_price
|
||||
))
|
||||
|
||||
# Update transaction total
|
||||
transaction.transaction_total_amount = total_amount
|
||||
return transaction
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
set_name: The name of the set to filter by
|
||||
skip: Number of records to skip (for pagination)
|
||||
limit: Maximum number of records to return
|
||||
async def create_customer(
|
||||
self,
|
||||
db: Session,
|
||||
customer_name: str
|
||||
) -> Customer:
|
||||
try:
|
||||
# check if customer already exists
|
||||
existing_customer = db.query(Customer).filter(Customer.name == customer_name).first()
|
||||
if existing_customer:
|
||||
return existing_customer
|
||||
|
||||
with db_transaction(db):
|
||||
customer = Customer(
|
||||
name=customer_name
|
||||
)
|
||||
db.add(customer)
|
||||
db.flush()
|
||||
return customer
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
async def create_vendor(
|
||||
self,
|
||||
db: Session,
|
||||
vendor_name: str
|
||||
) -> Vendor:
|
||||
try:
|
||||
# check if vendor already exists
|
||||
existing_vendor = db.query(Vendor).filter(Vendor.name == vendor_name).first()
|
||||
if existing_vendor:
|
||||
return existing_vendor
|
||||
|
||||
with db_transaction(db):
|
||||
vendor = Vendor(
|
||||
name=vendor_name
|
||||
)
|
||||
db.add(vendor)
|
||||
db.flush()
|
||||
return vendor
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
async def get_vendors(
|
||||
self,
|
||||
db: Session
|
||||
) -> List[Vendor]:
|
||||
return db.query(Vendor).all()
|
||||
|
||||
async def create_marketplace(
|
||||
self,
|
||||
db: Session,
|
||||
marketplace_name: str
|
||||
) -> Marketplace:
|
||||
try:
|
||||
# check if marketplace already exists
|
||||
existing_marketplace = db.query(Marketplace).filter(Marketplace.name == marketplace_name).first()
|
||||
if existing_marketplace:
|
||||
return existing_marketplace
|
||||
|
||||
with db_transaction(db):
|
||||
marketplace = Marketplace(
|
||||
name=marketplace_name
|
||||
)
|
||||
db.add(marketplace)
|
||||
db.flush()
|
||||
return marketplace
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
async def get_marketplaces(
|
||||
self,
|
||||
db: Session
|
||||
) -> List[Marketplace]:
|
||||
return db.query(Marketplace).all()
|
||||
|
||||
class BoxService(BaseService[Box]):
|
||||
def __init__(self):
|
||||
super().__init__(Box)
|
||||
|
||||
async def create_box(
|
||||
self,
|
||||
db: Session,
|
||||
product_id: int,
|
||||
cost_basis: float
|
||||
) -> Box:
|
||||
try:
|
||||
with db_transaction(db):
|
||||
# Create the SealedBox
|
||||
box = Box(
|
||||
tcgplayer_product_id=product_id
|
||||
)
|
||||
db.add(box)
|
||||
db.flush() # Get the ID for relationships
|
||||
|
||||
expected_value = box.products.sealed_expected_value.expected_value
|
||||
box.expected_value = expected_value
|
||||
db.flush()
|
||||
|
||||
# Create the InventoryItem for the sealed box
|
||||
inventory_item = InventoryItem(
|
||||
physical_item=box,
|
||||
cost_basis=cost_basis
|
||||
)
|
||||
db.add(inventory_item)
|
||||
|
||||
return box
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
async def calculate_cost_basis_for_opened_cards(self, db: Session, open_event: OpenEvent) -> float:
|
||||
box_cost_basis = open_event.source_item.inventory_item.cost_basis
|
||||
box_expected_value = open_event.source_item.products.sealed_expected_value.expected_value
|
||||
for resulting_card in open_event.resulting_items:
|
||||
# ensure card
|
||||
if resulting_card.item_type != "card":
|
||||
raise ValueError(f"Expected card, got {resulting_card.item_type}")
|
||||
resulting_card_market_value = resulting_card.products.most_recent_tcgplayer_price.market_price
|
||||
resulting_card_cost_basis = (resulting_card_market_value / box_expected_value) * box_cost_basis
|
||||
resulting_card.inventory_item.cost_basis = resulting_card_cost_basis
|
||||
db.flush()
|
||||
|
||||
async def open_box(self, db: Session, box: Box, manabox_file_uploads: List[FileInDB]) -> bool:
|
||||
with db_transaction(db):
|
||||
# create open event
|
||||
open_event = OpenEvent(
|
||||
source_item=box,
|
||||
open_date=datetime.now()
|
||||
)
|
||||
db.add(open_event)
|
||||
db.flush()
|
||||
|
||||
manabox_upload_ids = [manabox_file_upload.id for manabox_file_upload in manabox_file_uploads]
|
||||
staging_data = db.query(ManaboxImportStaging).filter(ManaboxImportStaging.file_id.in_(manabox_upload_ids)).all()
|
||||
for record in staging_data:
|
||||
for i in range(record.quantity):
|
||||
open_card = Card(
|
||||
tcgplayer_product_id=record.tcgplayer_product_id,
|
||||
tcgplayer_sku_id=record.tcgplayer_sku_id
|
||||
)
|
||||
open_event.resulting_items.append(open_card)
|
||||
|
||||
inventory_item = InventoryItem(
|
||||
physical_item=open_card,
|
||||
cost_basis=0
|
||||
)
|
||||
db.add(inventory_item)
|
||||
db.flush()
|
||||
|
||||
Returns:
|
||||
List[Inventory]: List of inventory items from the specified set
|
||||
"""
|
||||
return db.query(self.model).filter(self.model.set_name == set_name).offset(skip).limit(limit).all()
|
||||
# calculate cost basis for opened cards
|
||||
await self.calculate_cost_basis_for_opened_cards(db, open_event)
|
||||
|
||||
|
||||
|
||||
return open_event
|
||||
|
||||
|
||||
|
||||
class CaseService(BaseService[Case]):
|
||||
def __init__(self):
|
||||
super().__init__(Case)
|
||||
|
||||
async def create_case(self, db: Session, product_id: int, cost_basis: float, num_boxes: int) -> Case:
|
||||
try:
|
||||
with db_transaction(db):
|
||||
# Create the SealedCase
|
||||
case = Case(
|
||||
tcgplayer_product_id=product_id,
|
||||
num_boxes=num_boxes
|
||||
)
|
||||
db.add(case)
|
||||
db.flush() # Get the ID for relationships
|
||||
case.expected_value = case.products.sealed_expected_value.expected_value
|
||||
|
||||
# Create the InventoryItem for the sealed case
|
||||
inventory_item = InventoryItem(
|
||||
physical_item=case,
|
||||
cost_basis=cost_basis
|
||||
)
|
||||
db.add(inventory_item)
|
||||
|
||||
return case
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
async def open_case(self, db: Session, case: Case, child_product_id: int) -> bool:
|
||||
try:
|
||||
## TODO should be able to import a manabox file with a case
|
||||
## cost basis will be able to flow down to the card accurately
|
||||
with db_transaction(db):
|
||||
# Create the OpenEvent
|
||||
open_event = OpenEvent(
|
||||
source_item=case,
|
||||
open_date=datetime.now()
|
||||
)
|
||||
db.add(open_event)
|
||||
db.flush() # Get the ID for relationships
|
||||
|
||||
# Create num_boxes SealedBoxes
|
||||
for i in range(case.num_boxes):
|
||||
new_box = Box(
|
||||
tcgplayer_product_id=child_product_id
|
||||
)
|
||||
open_event.resulting_items.append(new_box)
|
||||
db.flush()
|
||||
|
||||
per_box_cost_basis = case.inventory_item.cost_basis / case.num_boxes
|
||||
|
||||
# Create the InventoryItem for the sealed box
|
||||
inventory_item = InventoryItem(
|
||||
physical_item=new_box,
|
||||
cost_basis=per_box_cost_basis
|
||||
)
|
||||
db.add(inventory_item)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
class MarketplaceListingService(BaseService[MarketplaceListing]):
|
||||
def __init__(self):
|
||||
super().__init__(MarketplaceListing)
|
||||
self.pricing_service = self.service_manager.get_service("pricing")
|
||||
|
||||
async def create_marketplace_listing(self, db: Session, inventory_item: InventoryItem, marketplace: Marketplace) -> MarketplaceListing:
|
||||
try:
|
||||
with db_transaction(db):
|
||||
recommended_price = await self.pricing_service.set_price_for_inventory_item(db, inventory_item)
|
||||
logger.info(f"recommended_price: {recommended_price.price}")
|
||||
marketplace_listing = MarketplaceListing(
|
||||
inventory_item=inventory_item,
|
||||
marketplace=marketplace,
|
||||
recommended_price=recommended_price,
|
||||
listing_date=None,
|
||||
delisting_date=None
|
||||
)
|
||||
db.add(marketplace_listing)
|
||||
db.flush()
|
||||
return marketplace_listing
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
async def update_marketplace_listing_price(self, db: Session, marketplace_listing: MarketplaceListing) -> MarketplaceListing:
|
||||
# idk if this was ever even finished so prob doesnt work idk
|
||||
try:
|
||||
with db_transaction(db):
|
||||
marketplace_listing.listed_price = self.pricing_service.set_price_for_inventory_item(db, marketplace_listing.inventory_item)
|
||||
db.flush()
|
||||
return marketplace_listing
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
async def get_marketplace_listing(self, db: Session, inventory_item: InventoryItem, marketplace: Marketplace) -> MarketplaceListing:
|
||||
return db.query(MarketplaceListing).filter(MarketplaceListing.inventory_item == inventory_item).filter(MarketplaceListing.delisting_date == None).filter(MarketplaceListing.deleted_at == None).filter(MarketplaceListing.marketplace == marketplace).order_by(MarketplaceListing.created_at.desc()).first()
|
||||
|
||||
async def get_active_marketplace_listing(self, db: Session, inventory_item: InventoryItem, marketplace: Marketplace) -> MarketplaceListing:
|
||||
return db.query(MarketplaceListing).filter(MarketplaceListing.inventory_item == inventory_item).filter(MarketplaceListing.delisting_date == None).filter(MarketplaceListing.deleted_at == None).filter(MarketplaceListing.marketplace == marketplace).filter(MarketplaceListing.listing_date != None).order_by(MarketplaceListing.created_at.desc()).first()
|
||||
|
||||
async def confirm_listings(self, db: Session, open_event: OpenEvent, marketplace: Marketplace) -> str:
|
||||
tcgplayer_add_file = await self.create_tcgplayer_add_file(db, open_event, marketplace)
|
||||
if not tcgplayer_add_file:
|
||||
raise ValueError("No TCGplayer add file created")
|
||||
with db_transaction(db):
|
||||
for resulting_item in open_event.resulting_items:
|
||||
marketplace_listing = await self.get_marketplace_listing(db, resulting_item.inventory_item, marketplace)
|
||||
if marketplace_listing is None:
|
||||
raise ValueError(f"No active marketplace listing found for inventory item id {resulting_item.inventory_item.id} in {marketplace.name}")
|
||||
marketplace_listing.listing_date = datetime.now()
|
||||
db.flush()
|
||||
return tcgplayer_add_file
|
||||
|
||||
async def create_tcgplayer_add_file(self, db: Session, open_event: OpenEvent, marketplace: Marketplace) -> str:
|
||||
# TCGplayer Id,Product Line,Set Name,Product Name,Title,Number,Rarity,Condition,TCG Market Price,TCG Direct Low,TCG Low Price With Shipping,TCG Low Price,Total Quantity,Add to Quantity,TCG Marketplace Price,Photo URL
|
||||
headers = [
|
||||
"TCGplayer Id",
|
||||
"Product Line",
|
||||
"Set Name",
|
||||
"Product Name",
|
||||
"Title",
|
||||
"Number",
|
||||
"Rarity",
|
||||
"Condition",
|
||||
"TCG Market Price",
|
||||
"TCG Direct Low",
|
||||
"TCG Low Price With Shipping",
|
||||
"TCG Low Price",
|
||||
"Total Quantity",
|
||||
"Add to Quantity",
|
||||
"TCG Marketplace Price",
|
||||
"Photo URL"
|
||||
]
|
||||
data = {}
|
||||
for resulting_item in open_event.resulting_items:
|
||||
marketplace_listing = await self.get_marketplace_listing(db, resulting_item.inventory_item, marketplace)
|
||||
if marketplace_listing is None:
|
||||
raise ValueError(f"No active marketplace listing found for inventory item id {resulting_item.inventory_item.id} in {marketplace.name}")
|
||||
tcgplayer_sku_id = resulting_item.tcgplayer_sku_id
|
||||
if tcgplayer_sku_id in data:
|
||||
data[tcgplayer_sku_id]["Add to Quantity"] += 1
|
||||
continue
|
||||
product_line = resulting_item.products.category.name
|
||||
set_name = resulting_item.products.group.name
|
||||
product_name = resulting_item.products.name
|
||||
title = ""
|
||||
number = resulting_item.products.ext_number
|
||||
rarity = resulting_item.products.ext_rarity
|
||||
condition = " ".join([condition.capitalize() for condition in resulting_item.sku.condition.split(" ")]) + (" " + resulting_item.products.sub_type_name if resulting_item.products.sub_type_name == "Foil" else "")
|
||||
tcg_market_price = resulting_item.products.most_recent_tcgplayer_price.market_price
|
||||
tcg_direct_low = resulting_item.products.most_recent_tcgplayer_price.direct_low_price
|
||||
tcg_low_price_with_shipping = resulting_item.products.most_recent_tcgplayer_price.low_price
|
||||
tcg_low_price = resulting_item.products.most_recent_tcgplayer_price.low_price
|
||||
total_quantity = ""
|
||||
add_to_quantity = 1
|
||||
# get average recommended price of product
|
||||
# get inventory items with same tcgplayer_product_id
|
||||
inventory_items = db.query(InventoryItem).filter(InventoryItem.physical_item.has(tcgplayer_sku_id=tcgplayer_sku_id)).all()
|
||||
inventory_item_ids = [inventory_item.id for inventory_item in inventory_items]
|
||||
logger.debug(f"inventory_item_ids: {inventory_item_ids}")
|
||||
valid_listings = db.query(MarketplaceListing).filter(MarketplaceListing.inventory_item_id.in_(inventory_item_ids)).filter(MarketplaceListing.delisting_date == None).filter(MarketplaceListing.deleted_at == None).filter(MarketplaceListing.listing_date == None).all()
|
||||
logger.debug(f"valid_listings: {valid_listings}")
|
||||
avg_recommended_price = sum([listing.recommended_price.price for listing in valid_listings]) / len(valid_listings)
|
||||
data[tcgplayer_sku_id] = {
|
||||
"TCGplayer Id": tcgplayer_sku_id,
|
||||
"Product Line": product_line,
|
||||
"Set Name": set_name,
|
||||
"Product Name": product_name,
|
||||
"Title": title,
|
||||
"Number": number,
|
||||
"Rarity": rarity,
|
||||
"Condition": condition,
|
||||
"TCG Market Price": tcg_market_price,
|
||||
"TCG Direct Low": tcg_direct_low,
|
||||
"TCG Low Price With Shipping": tcg_low_price_with_shipping,
|
||||
"TCG Low Price": tcg_low_price,
|
||||
"Total Quantity": total_quantity,
|
||||
"Add to Quantity": add_to_quantity,
|
||||
"TCG Marketplace Price": f"{Decimal(avg_recommended_price):.2f}",
|
||||
"Photo URL": ""
|
||||
}
|
||||
# format data into csv
|
||||
# header
|
||||
header_row = ",".join(headers)
|
||||
# data
|
||||
def escape_csv_value(value):
|
||||
if value is None:
|
||||
return ""
|
||||
value = str(value)
|
||||
if any(c in value for c in [',', '"', '\n']):
|
||||
return f'"{value.replace('"', '""')}"'
|
||||
return value
|
||||
|
||||
data_rows = [",".join([escape_csv_value(data[tcgplayer_id][header]) for header in headers]) for tcgplayer_id in data]
|
||||
csv_data = "\n".join([header_row] + data_rows)
|
||||
return csv_data
|
||||
|
||||
async def create_tcgplayer_update_file(self, db: Session, marketplace: Marketplace=None) -> str:
|
||||
# TCGplayer Id,Product Line,Set Name,Product Name,Title,Number,Rarity,Condition,TCG Market Price,TCG Direct Low,TCG Low Price With Shipping,TCG Low Price,Total Quantity,Add to Quantity,TCG Marketplace Price,Photo URL
|
||||
headers = [
|
||||
"TCGplayer Id",
|
||||
"Product Line",
|
||||
"Set Name",
|
||||
"Product Name",
|
||||
"Title",
|
||||
"Number",
|
||||
"Rarity",
|
||||
"Condition",
|
||||
"TCG Market Price",
|
||||
"TCG Direct Low",
|
||||
"TCG Low Price With Shipping",
|
||||
"TCG Low Price",
|
||||
"Total Quantity",
|
||||
"Add to Quantity",
|
||||
"TCG Marketplace Price",
|
||||
"Photo URL"
|
||||
]
|
||||
unmanaged_inventory = await self.pricing_service.update_prices_for_unmanaged_inventory(db)
|
||||
managed_inventory = await self.pricing_service.update_prices_for_managed_inventory(db)
|
||||
# combine and convert to csv
|
||||
inventory = unmanaged_inventory + managed_inventory
|
||||
data = {}
|
||||
for inventory_item in inventory:
|
||||
data[inventory_item.tcgplayer_sku_id] = {
|
||||
"TCGplayer Id": inventory_item.tcgplayer_sku_id,
|
||||
"Product Line": inventory_item.product_line,
|
||||
"Set Name": inventory_item.set_name,
|
||||
"Product Name": inventory_item.product_name,
|
||||
"Title": inventory_item.title,
|
||||
"Number": inventory_item.number,
|
||||
"Rarity": inventory_item.rarity,
|
||||
"Condition": inventory_item.condition,
|
||||
"TCG Market Price": inventory_item.tcg_market_price,
|
||||
"TCG Direct Low": inventory_item.tcg_direct_low,
|
||||
"TCG Low Price With Shipping": inventory_item.tcg_low_price_with_shipping,
|
||||
"TCG Low Price": inventory_item.tcg_low_price,
|
||||
"Total Quantity": "",
|
||||
"Add to Quantity": "0",
|
||||
"TCG Marketplace Price": f"{Decimal(inventory_item.tcg_marketplace_price):.2f}",
|
||||
"Photo URL": ""
|
||||
}
|
||||
# format data into csv
|
||||
# header
|
||||
header_row = ",".join(headers)
|
||||
# data
|
||||
def escape_csv_value(value):
|
||||
if value is None:
|
||||
return ""
|
||||
value = str(value)
|
||||
if any(c in value for c in [',', '"', '\n']):
|
||||
return f'"{value.replace('"', '""')}"'
|
||||
return value
|
||||
data_rows = [",".join([escape_csv_value(data[tcgplayer_id][header]) for header in headers]) for tcgplayer_id in data]
|
||||
csv_data = "\n".join([header_row] + data_rows)
|
||||
return csv_data
|
@ -142,13 +142,14 @@ class LabelPrinterService:
|
||||
logger.error(f"Unexpected error in _send_print_request: {e}")
|
||||
return False
|
||||
|
||||
async def print_file(self, file_path: Union[str, Path, FileInDB], label_size: Literal["dk1201", "dk1241"], label_type: Optional[Literal["address_label", "packing_slip", "set_label"]] = None) -> bool:
|
||||
async def print_file(self, file_path: Union[str, Path, FileInDB], label_size: Literal["dk1201", "dk1241"], label_type: Optional[Literal["address_label", "packing_slip", "set_label", "return_label", "pirate_ship_label"]] = None, copies: Optional[int] = None) -> bool:
|
||||
"""Print a PDF or PNG file to the label printer.
|
||||
|
||||
Args:
|
||||
file_path: Path to the PDF or PNG file, or a FileInDB object
|
||||
label_size: Size of label to use ("dk1201" or "dk1241")
|
||||
label_type: Type of label to use ("address_label" or "packing_slip" or "set_label")
|
||||
copies: Optional number of copies to print. If None, prints once.
|
||||
|
||||
Returns:
|
||||
bool: True if print was successful, False otherwise
|
||||
@ -206,7 +207,7 @@ class LabelPrinterService:
|
||||
resized_image = resized_image.resize((991, 306), Image.Resampling.LANCZOS)
|
||||
|
||||
# if file path contains address_label, rotate image 90 degrees
|
||||
if label_type == "address_label" or label_type == "set_label":
|
||||
if label_type == "address_label" or label_type == "set_label" or label_type == "return_label":
|
||||
rotate = "90"
|
||||
cut = False
|
||||
else:
|
||||
@ -240,16 +241,30 @@ class LabelPrinterService:
|
||||
with open(cache_path, "wb") as f:
|
||||
f.write(converted_image)
|
||||
|
||||
# Send to API
|
||||
if not await self._send_print_request(cache_path):
|
||||
logger.error(f"Failed to print page {i+1}")
|
||||
return False
|
||||
|
||||
# Wait for printer to be ready before processing next page
|
||||
if i < len(images) - 1: # Don't wait after the last page
|
||||
if not await self._wait_for_printer_ready():
|
||||
logger.error("Printer not ready for next page")
|
||||
if copies:
|
||||
# Send to API for each copy
|
||||
for copy in range(copies):
|
||||
logger.info(f"Printing copy {copy + 1} of {copies}")
|
||||
if not await self._send_print_request(cache_path):
|
||||
logger.error(f"Failed to print page {i+1}, copy {copy + 1}")
|
||||
return False
|
||||
|
||||
# Wait for printer to be ready before next copy or page
|
||||
if copy < copies - 1 or i < len(images) - 1:
|
||||
if not await self._wait_for_printer_ready():
|
||||
logger.error("Printer not ready for next copy/page")
|
||||
return False
|
||||
else:
|
||||
# Send to API once (original behavior)
|
||||
if not await self._send_print_request(cache_path):
|
||||
logger.error(f"Failed to print page {i+1}")
|
||||
return False
|
||||
|
||||
# Wait for printer to be ready before processing next page
|
||||
if i < len(images) - 1: # Don't wait after the last page
|
||||
if not await self._wait_for_printer_ready():
|
||||
logger.error("Printer not ready for next page")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
139
app/services/manabox_service.py
Normal file
139
app/services/manabox_service.py
Normal file
@ -0,0 +1,139 @@
|
||||
from app.services.base_service import BaseService
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import transaction
|
||||
from app.schemas.file import FileInDB
|
||||
from app.models.tcgplayer_products import TCGPlayerProduct, MTGJSONCard, MTGJSONSKU
|
||||
from app.models.critical_error_log import CriticalErrorLog
|
||||
from app.models.manabox_import_staging import ManaboxImportStaging
|
||||
from typing import Dict, Any, Union, List
|
||||
import csv
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from fastapi import BackgroundTasks
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ManaboxService(BaseService):
|
||||
def __init__(self):
|
||||
super().__init__(None)
|
||||
|
||||
async def process_manabox_csv(self, db: Session, bytes: bytes, metadata: Dict[str, Any], background_tasks: BackgroundTasks, wait: bool = False) -> Union[bool, List[FileInDB]]:
|
||||
# save file
|
||||
file = await self.file_service.save_file(
|
||||
db=db,
|
||||
file_data=bytes,
|
||||
filename=f"manabox_{datetime.now().strftime('%Y%m%d%H%M%S')}.csv",
|
||||
subdir="manabox",
|
||||
file_type="manabox",
|
||||
content_type="text/csv",
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
if wait:
|
||||
await self._process_file_background(db, file)
|
||||
return_value = await self.file_service.get_file(db, file.id)
|
||||
return [return_value] if return_value else []
|
||||
else:
|
||||
background_tasks.add_task(self._process_file_background, db, file)
|
||||
return True
|
||||
|
||||
async def _process_file_background(self, db: Session, file: FileInDB):
|
||||
try:
|
||||
# Read the CSV file
|
||||
with open(file.path, 'r') as csv_file:
|
||||
reader = csv.DictReader(csv_file)
|
||||
logger.debug(f"Processing file: {file.path}")
|
||||
# Pre-fetch all MTGJSONCards for Scryfall IDs in the file
|
||||
scryfall_ids = {row['Scryfall ID'] for row in reader}
|
||||
mtg_json_map = {card.scryfall_id: card for card in db.query(MTGJSONCard).filter(MTGJSONCard.scryfall_id.in_(scryfall_ids)).all()}
|
||||
logger.debug(f"len ids: {len(scryfall_ids)}")
|
||||
|
||||
# Re-read the file to process the rows
|
||||
csv_file.seek(0)
|
||||
logger.debug(f"header: {reader.fieldnames}")
|
||||
next(reader) # Skip the header row
|
||||
|
||||
staging_entries = [] # To collect all staging entries for batch insert
|
||||
critical_errors = [] # To collect errors for logging
|
||||
|
||||
for row in reader:
|
||||
|
||||
logger.debug(f"Processing row: {row}")
|
||||
mtg_json = mtg_json_map.get(row['Scryfall ID'])
|
||||
|
||||
if not mtg_json:
|
||||
error_message = f"Error: No MTGJSONCard found for scryfall id: {row['Scryfall ID']}"
|
||||
critical_errors.append(error_message)
|
||||
continue # Skip this row
|
||||
|
||||
language = 'ENGLISH' if row['Language'] == 'en' else 'JAPANESE' if row['Language'] == 'ja' else None # manabox only needs en and jp for now
|
||||
printing = 'foil' if 'foil' in row['Foil'].lower() or 'etched' in row['Foil'].lower() else 'normal'
|
||||
condition = row['Condition'].replace('_', ' ').upper()
|
||||
|
||||
# Query the correct TCGPlayer SKU
|
||||
sku_query = db.query(MTGJSONSKU).filter(
|
||||
MTGJSONSKU.tcgplayer_product_id == (mtg_json.tcgplayer_etched_product_id if row['Foil'].lower() == 'etched' else mtg_json.tcgplayer_product_id)
|
||||
).filter(
|
||||
MTGJSONSKU.condition == condition,
|
||||
MTGJSONSKU.normalized_printing == printing,
|
||||
MTGJSONSKU.language == language
|
||||
).distinct()
|
||||
|
||||
if sku_query.count() != 1:
|
||||
error_message = f"Error: Multiple TCGplayer SKUs found for mtgjson name: {mtg_json.name} condition: {row['Condition']} language: {language} printing: {printing}"
|
||||
critical_errors.append(error_message)
|
||||
continue # Skip this row
|
||||
|
||||
tcgplayer_sku = sku_query.first()
|
||||
|
||||
if not tcgplayer_sku:
|
||||
error_message = f"Error: No TCGplayer SKU found for mtgjson name: {mtg_json.name} condition: {row['Condition']} language: {language} printing: {printing}"
|
||||
critical_errors.append(error_message)
|
||||
continue # Skip this row
|
||||
|
||||
# Query TCGPlayer product data
|
||||
tcgplayer_product = db.query(TCGPlayerProduct).filter(
|
||||
TCGPlayerProduct.tcgplayer_product_id == tcgplayer_sku.tcgplayer_product_id,
|
||||
TCGPlayerProduct.normalized_sub_type_name == tcgplayer_sku.normalized_printing
|
||||
).distinct()
|
||||
|
||||
if tcgplayer_product.count() != 1:
|
||||
error_message = f"Error: Multiple TCGPlayer products found for SKU {tcgplayer_sku.tcgplayer_sku_id}"
|
||||
critical_errors.append(error_message)
|
||||
continue # Skip this row
|
||||
|
||||
tcgplayer_product = tcgplayer_product.first()
|
||||
|
||||
if not tcgplayer_product:
|
||||
error_message = f"Error: No TCGPlayer product found for SKU {tcgplayer_sku.tcgplayer_sku_id}"
|
||||
critical_errors.append(error_message)
|
||||
continue # Skip this row
|
||||
|
||||
# Prepare the staging entry
|
||||
quantity = int(row['Quantity'])
|
||||
logger.debug(f"inserting row file id: {file.id} tcgplayer_product_id: {tcgplayer_product.tcgplayer_product_id} tcgplayer_sku_id: {tcgplayer_sku.tcgplayer_sku_id} quantity: {quantity}")
|
||||
staging_entries.append(ManaboxImportStaging(
|
||||
file_id=file.id,
|
||||
tcgplayer_product_id=tcgplayer_product.tcgplayer_product_id,
|
||||
tcgplayer_sku_id=tcgplayer_sku.tcgplayer_sku_id,
|
||||
quantity=quantity
|
||||
))
|
||||
|
||||
# Bulk insert all valid ManaboxImportStaging entries
|
||||
if staging_entries:
|
||||
logger.debug(f"inserting {len(staging_entries)} rows")
|
||||
with transaction(db):
|
||||
db.bulk_save_objects(staging_entries)
|
||||
|
||||
# Log any critical errors that occurred
|
||||
for error_message in critical_errors:
|
||||
logger.debug(f"logging critical error: {error_message}")
|
||||
with transaction(db):
|
||||
critical_error_log = CriticalErrorLog(error_message=error_message)
|
||||
db.add(critical_error_log)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing file: {str(e)}")
|
||||
with transaction(db):
|
||||
critical_error_log = CriticalErrorLog(error_message=f"Error processing file: {str(e)}")
|
||||
db.add(critical_error_log)
|
341
app/services/pricing_service.py
Normal file
341
app/services/pricing_service.py
Normal file
@ -0,0 +1,341 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict, List
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
from app.services.base_service import BaseService
|
||||
from app.models.inventory_management import InventoryItem, MarketplaceListing, PhysicalItem
|
||||
from app.models.tcgplayer_inventory import TCGPlayerInventory
|
||||
from app.models.tcgplayer_products import TCGPlayerPriceHistory, TCGPlayerProduct, MTGJSONSKU
|
||||
from app.models.pricing import PricingEvent
|
||||
from app.db.database import transaction
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class PriceData:
|
||||
cost_basis: Optional[Decimal]
|
||||
market_price: Optional[Decimal]
|
||||
tcg_low: Optional[Decimal]
|
||||
tcg_mid: Optional[Decimal]
|
||||
direct_low: Optional[Decimal]
|
||||
listed_price: Optional[Decimal]
|
||||
quantity: int
|
||||
lowest_price_for_qty: Optional[Decimal]
|
||||
velocity: Optional[Decimal]
|
||||
age_of_inventory: Optional[int]
|
||||
|
||||
|
||||
class PricingService(BaseService):
|
||||
def __init__(self):
|
||||
super().__init__(None)
|
||||
|
||||
async def get_unmanaged_inventory(self, db: Session):
|
||||
unmanaged_inventory = db.query(TCGPlayerInventory).filter(
|
||||
TCGPlayerInventory.tcgplayer_sku_id.notin_(
|
||||
db.query(PhysicalItem.tcgplayer_sku_id).join(
|
||||
InventoryItem, PhysicalItem.id == InventoryItem.physical_item_id
|
||||
).join(
|
||||
MarketplaceListing, InventoryItem.id == MarketplaceListing.inventory_item_id
|
||||
).filter(
|
||||
MarketplaceListing.delisting_date.is_(None),
|
||||
MarketplaceListing.deleted_at.is_(None),
|
||||
InventoryItem.deleted_at.is_(None),
|
||||
PhysicalItem.deleted_at.is_(None)
|
||||
)
|
||||
),
|
||||
TCGPlayerInventory.total_quantity >= 1
|
||||
).all()
|
||||
return unmanaged_inventory
|
||||
|
||||
async def get_managed_inventory(self, db: Session):
|
||||
# First get the TCGPlayerInventory IDs that are managed
|
||||
managed_ids = select(TCGPlayerInventory.id).join(
|
||||
PhysicalItem, TCGPlayerInventory.tcgplayer_sku_id == PhysicalItem.tcgplayer_sku_id
|
||||
).join(
|
||||
InventoryItem, PhysicalItem.id == InventoryItem.physical_item_id
|
||||
).join(
|
||||
MarketplaceListing, InventoryItem.id == MarketplaceListing.inventory_item_id
|
||||
).filter(
|
||||
MarketplaceListing.delisting_date.is_(None),
|
||||
MarketplaceListing.deleted_at.is_(None),
|
||||
InventoryItem.deleted_at.is_(None),
|
||||
PhysicalItem.deleted_at.is_(None)
|
||||
)
|
||||
|
||||
# Then get just the TCGPlayerInventory data for those IDs
|
||||
managed_inventory = db.query(TCGPlayerInventory).filter(
|
||||
TCGPlayerInventory.id.in_(managed_ids)
|
||||
).all()
|
||||
return managed_inventory
|
||||
|
||||
async def get_pricing_data_for_unmanaged_inventory(self, db: Session) -> Dict[int, PriceData]:
|
||||
"""Gather all pricing data for unmanaged inventory in a single query."""
|
||||
unmanaged_inventory = await self.get_unmanaged_inventory(db)
|
||||
|
||||
# Get all SKU IDs
|
||||
sku_ids = [inv.tcgplayer_sku_id for inv in unmanaged_inventory]
|
||||
|
||||
# Fetch all MTGJSON SKUs and their products in one query
|
||||
mtgjson_skus = db.query(MTGJSONSKU).filter(
|
||||
MTGJSONSKU.tcgplayer_sku_id.in_(sku_ids)
|
||||
).all()
|
||||
|
||||
# Create a mapping of SKU ID to MTGJSON SKU
|
||||
sku_map = {sku.tcgplayer_sku_id: sku for sku in mtgjson_skus}
|
||||
|
||||
# Create price data for each inventory item
|
||||
price_data_map = {}
|
||||
for inventory in unmanaged_inventory:
|
||||
mtgjson_sku = sku_map.get(inventory.tcgplayer_sku_id)
|
||||
if mtgjson_sku and mtgjson_sku.product and mtgjson_sku.product.most_recent_tcgplayer_price:
|
||||
recent_price = mtgjson_sku.product.most_recent_tcgplayer_price
|
||||
price_data = PriceData(
|
||||
cost_basis=None,
|
||||
market_price=Decimal(str(recent_price.market_price)) if recent_price.market_price else None,
|
||||
tcg_low=Decimal(str(recent_price.low_price)) if recent_price.low_price else None,
|
||||
tcg_mid=Decimal(str(recent_price.mid_price)) if recent_price.mid_price else None,
|
||||
direct_low=Decimal(str(recent_price.direct_low_price)) if recent_price.direct_low_price else None,
|
||||
listed_price=Decimal(str(inventory.tcg_marketplace_price)) if inventory.tcg_marketplace_price else None,
|
||||
quantity=inventory.total_quantity,
|
||||
lowest_price_for_qty=None,
|
||||
velocity=None,
|
||||
age_of_inventory=None
|
||||
)
|
||||
price_data_map[inventory.tcgplayer_sku_id] = price_data
|
||||
|
||||
return price_data_map
|
||||
|
||||
async def update_prices_for_unmanaged_inventory(self, db: Session):
|
||||
# Get all pricing data upfront
|
||||
price_data_map = await self.get_pricing_data_for_unmanaged_inventory(db)
|
||||
|
||||
# Update prices using the pre-fetched data
|
||||
unmanaged_inventory = await self.get_unmanaged_inventory(db)
|
||||
for inventory in unmanaged_inventory:
|
||||
price_data = price_data_map.get(inventory.tcgplayer_sku_id)
|
||||
if price_data:
|
||||
inventory.tcg_marketplace_price = await self.set_price(db, price_data)
|
||||
|
||||
return unmanaged_inventory
|
||||
|
||||
async def update_prices_for_managed_inventory(self, db: Session):
|
||||
"""Update prices for managed inventory items and return updated TCGPlayerInventory data."""
|
||||
managed_inventory = await self.get_managed_inventory(db)
|
||||
|
||||
# Get all the inventory items we need in one query
|
||||
inventory_items = db.query(InventoryItem).join(
|
||||
PhysicalItem, InventoryItem.physical_item_id == PhysicalItem.id
|
||||
).filter(
|
||||
PhysicalItem.tcgplayer_sku_id.in_([inv.tcgplayer_sku_id for inv in managed_inventory]),
|
||||
InventoryItem.deleted_at.is_(None)
|
||||
).all()
|
||||
|
||||
# Create a map of sku_id to inventory_item for easy lookup
|
||||
inventory_map = {item.physical_item.tcgplayer_sku_id: item for item in inventory_items}
|
||||
|
||||
for tcg_inventory in managed_inventory:
|
||||
inventory_item = inventory_map.get(tcg_inventory.tcgplayer_sku_id)
|
||||
if inventory_item:
|
||||
pricing_event = await self.set_price_for_inventory_item(db, inventory_item)
|
||||
if pricing_event:
|
||||
tcg_inventory.tcg_marketplace_price = pricing_event.price
|
||||
|
||||
return managed_inventory
|
||||
|
||||
async def set_price_for_inventory_item(self, db: Session, inventory_item: InventoryItem):
|
||||
recent_price = inventory_item.physical_item.sku.product.most_recent_tcgplayer_price
|
||||
|
||||
# Get the most recent active marketplace listing
|
||||
active_listing = None
|
||||
if inventory_item.marketplace_listing:
|
||||
active_listings = [listing for listing in inventory_item.marketplace_listing
|
||||
if listing.delisting_date is None and listing.deleted_at is None]
|
||||
if active_listings:
|
||||
active_listing = active_listings[0] # Get the first active listing
|
||||
|
||||
price_data = PriceData(
|
||||
cost_basis=Decimal(str(inventory_item.cost_basis)) if inventory_item.cost_basis is not None else None,
|
||||
market_price=Decimal(str(recent_price.market_price)) if recent_price and recent_price.market_price is not None else None,
|
||||
tcg_low=Decimal(str(recent_price.low_price)) if recent_price and recent_price.low_price is not None else None,
|
||||
tcg_mid=Decimal(str(recent_price.mid_price)) if recent_price and recent_price.mid_price is not None else None,
|
||||
direct_low=Decimal(str(recent_price.direct_low_price)) if recent_price and recent_price.direct_low_price is not None else None,
|
||||
listed_price=Decimal(str(active_listing.listed_price.price)) if active_listing and active_listing.listed_price and active_listing.listed_price.price is not None else None,
|
||||
quantity=db.query(TCGPlayerInventory).filter(
|
||||
TCGPlayerInventory.tcgplayer_sku_id == inventory_item.physical_item.tcgplayer_sku_id
|
||||
).first().total_quantity if db.query(TCGPlayerInventory).filter(
|
||||
TCGPlayerInventory.tcgplayer_sku_id == inventory_item.physical_item.tcgplayer_sku_id
|
||||
).first() else 0,
|
||||
lowest_price_for_qty=None,
|
||||
velocity=None,
|
||||
age_of_inventory=None
|
||||
)
|
||||
return await self.set_price(db, price_data, inventory_item)
|
||||
|
||||
async def set_price(self, db: Session, price_data: PriceData, inventory_item: InventoryItem=None):
|
||||
"""
|
||||
TODO This sets listed_price per inventory_item but listed_price can only be applied to a product
|
||||
however, this may be desired on other marketplaces
|
||||
when generating pricing file for tcgplayer, give the option to set min, max, avg price for product?
|
||||
"""
|
||||
# Fetch base pricing data
|
||||
cost_basis = price_data.cost_basis
|
||||
market_price = price_data.market_price
|
||||
tcg_low = price_data.tcg_low
|
||||
tcg_mid = price_data.tcg_mid
|
||||
listed_price = price_data.listed_price
|
||||
|
||||
if inventory_item:
|
||||
# average cost basis for all inventory items with the same tcgplayer_sku_id
|
||||
average_cost_basis = db.query(InventoryItem.cost_basis).filter(
|
||||
InventoryItem.physical_item_id == inventory_item.physical_item_id
|
||||
).all()
|
||||
cost_basis_values = [row[0] for row in average_cost_basis if row[0] is not None]
|
||||
if cost_basis_values:
|
||||
cost_basis = Decimal(str(sum(cost_basis_values) / len(cost_basis_values)))
|
||||
|
||||
logger.info(f"listed_price: {listed_price}")
|
||||
logger.info(f"market_price: {market_price}")
|
||||
logger.info(f"tcg_low: {tcg_low}")
|
||||
logger.info(f"tcg_mid: {tcg_mid}")
|
||||
logger.info(f"cost_basis: {cost_basis}")
|
||||
|
||||
# TODO: Add logic to fetch lowest price for seller with same quantity in stock
|
||||
# NOT IMPLEMENTED YET
|
||||
lowest_price_for_quantity = Decimal('0.0')
|
||||
|
||||
# Hardcoded configuration values (should be parameterized later)
|
||||
shipping_cost = Decimal('1.0')
|
||||
tcgplayer_shipping_fee = Decimal('1.31')
|
||||
average_cards_per_order = Decimal('3.0')
|
||||
marketplace_fee_percentage = Decimal('0.20')
|
||||
target_margin = Decimal('0.10')
|
||||
velocity_multiplier = Decimal('0.0')
|
||||
global_margin_multiplier = Decimal('0.00')
|
||||
min_floor_price = Decimal('0.25')
|
||||
price_drop_threshold = Decimal('0.50')
|
||||
# TODO add age of inventory price decrease multiplier
|
||||
age_of_inventory_multiplier = Decimal('0.0')
|
||||
|
||||
# card cost margin multiplier
|
||||
if market_price > 0 and market_price < 2:
|
||||
card_cost_margin_multiplier = Decimal('-0.033')
|
||||
elif market_price >= 2 and market_price < 10:
|
||||
card_cost_margin_multiplier = Decimal('0.0')
|
||||
elif market_price >= 10 and market_price < 30:
|
||||
card_cost_margin_multiplier = Decimal('0.0125')
|
||||
elif market_price >= 30 and market_price < 50:
|
||||
card_cost_margin_multiplier = Decimal('0.025')
|
||||
elif market_price >= 50 and market_price < 100:
|
||||
card_cost_margin_multiplier = Decimal('0.033')
|
||||
elif market_price >= 100 and market_price < 200:
|
||||
card_cost_margin_multiplier = Decimal('0.05')
|
||||
else:
|
||||
card_cost_margin_multiplier = Decimal('0.0')
|
||||
|
||||
# Fetch current total quantity in stock for SKU
|
||||
quantity_in_stock = price_data.quantity
|
||||
|
||||
# Determine quantity multiplier based on stock levels
|
||||
if quantity_in_stock < 4:
|
||||
quantity_multiplier = Decimal('0.0')
|
||||
elif quantity_in_stock == 4:
|
||||
quantity_multiplier = Decimal('0.2')
|
||||
elif 5 <= quantity_in_stock < 10:
|
||||
quantity_multiplier = Decimal('0.3')
|
||||
elif quantity_in_stock >= 10:
|
||||
quantity_multiplier = Decimal('0.4')
|
||||
else:
|
||||
quantity_multiplier = Decimal('0.0')
|
||||
|
||||
# Calculate adjusted target margin from base and global multipliers
|
||||
adjusted_target_margin = target_margin + global_margin_multiplier + card_cost_margin_multiplier
|
||||
|
||||
# limit shipping cost offset to 10% of market price
|
||||
shipping_cost_offset = min(shipping_cost / average_cards_per_order, market_price * Decimal('0.1'))
|
||||
|
||||
if cost_basis is None:
|
||||
cost_basis = tcg_mid * Decimal('0.65')
|
||||
# Calculate base price considering cost, shipping, fees, and margin targets
|
||||
base_price = (cost_basis + shipping_cost_offset) / (
|
||||
(Decimal('1.0') - marketplace_fee_percentage) - adjusted_target_margin
|
||||
)
|
||||
|
||||
# Adjust base price by quantity and velocity multipliers, limit markup to amount of shipping fee
|
||||
adjusted_price = min(
|
||||
base_price * (Decimal('1.0') + quantity_multiplier + velocity_multiplier - age_of_inventory_multiplier),
|
||||
base_price + tcgplayer_shipping_fee
|
||||
)
|
||||
|
||||
# Adjust price based on market prices (TCG low and TCG mid)
|
||||
if adjusted_price < tcg_low:
|
||||
adjusted_price = tcg_mid
|
||||
price_used = "tcg mid"
|
||||
price_reason = "adjusted price below tcg low"
|
||||
elif adjusted_price > tcg_low and adjusted_price < (tcg_mid * Decimal('0.85')):
|
||||
adjusted_price = tcg_mid
|
||||
price_used = "tcg mid"
|
||||
price_reason = f"adjusted price below 80% of tcg mid"
|
||||
elif adjusted_price > (tcg_mid * Decimal('1.1')):
|
||||
adjusted_price = max(tcg_mid, cost_basis)
|
||||
price_used = "max tcg mid/cost basis"
|
||||
price_reason = f"adjusted price above 110% of tcg mid, using max of tcg mid and cost basis"
|
||||
else:
|
||||
price_used = "adjusted price"
|
||||
price_reason = "valid price assigned based on margin targets"
|
||||
|
||||
# TODO: Add logic to adjust price to beat competitor price with same quantity
|
||||
# NOT IMPLEMENTED YET
|
||||
if adjusted_price < lowest_price_for_quantity:
|
||||
adjusted_price = lowest_price_for_quantity - Decimal('0.01')
|
||||
price_used = "lowest price for quantity"
|
||||
price_reason = "adjusted price below lowest price for quantity"
|
||||
|
||||
# Fine-tune price to optimize for free shipping promotions
|
||||
free_shipping_adjustment = False
|
||||
for x in range(1, 5):
|
||||
quantity = Decimal(str(x))
|
||||
if Decimal('5.00') <= adjusted_price * quantity <= Decimal('5.15'):
|
||||
adjusted_price = Decimal('4.99') / quantity
|
||||
free_shipping_adjustment = True
|
||||
break
|
||||
|
||||
# prevent price drop over price drop threshold
|
||||
if listed_price and adjusted_price < (listed_price * (1 - price_drop_threshold)):
|
||||
adjusted_price = listed_price
|
||||
price_used = "listed price"
|
||||
price_reason = "adjusted price below price drop threshold"
|
||||
|
||||
# Enforce minimum floor price
|
||||
if adjusted_price < min_floor_price:
|
||||
adjusted_price = min_floor_price
|
||||
price_used = "min floor price"
|
||||
price_reason = "adjusted price below min floor price"
|
||||
|
||||
# Record pricing event in database transaction
|
||||
if inventory_item:
|
||||
with transaction(db):
|
||||
pricing_event = PricingEvent(
|
||||
inventory_item_id=inventory_item.id,
|
||||
price=float(adjusted_price),
|
||||
price_used=price_used,
|
||||
price_reason=price_reason,
|
||||
free_shipping_adjustment=free_shipping_adjustment
|
||||
)
|
||||
db.add(pricing_event)
|
||||
|
||||
# delete previous pricing events for inventory item
|
||||
if inventory_item.marketplace_listing:
|
||||
for listing in inventory_item.marketplace_listing:
|
||||
if listing.listed_price:
|
||||
listing.listed_price.deleted_at = datetime.now()
|
||||
db.flush()
|
||||
listing.listed_price = pricing_event
|
||||
|
||||
return pricing_event
|
||||
else:
|
||||
return adjusted_price
|
||||
# BAD BAD BAD FIX PLS TODO
|
@ -1,4 +1,5 @@
|
||||
from typing import List, Dict
|
||||
import json
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@ -9,7 +10,7 @@ import asyncio
|
||||
from app.schemas.file import FileInDB
|
||||
from app.services.base_service import BaseService
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.tcgplayer_products import TCGPlayerProduct, TCGPlayerGroup, MTGJSONSKU, MTGJSONCard
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -48,7 +49,7 @@ class PullSheetService(BaseService):
|
||||
"""
|
||||
try:
|
||||
# Read and process CSV data
|
||||
items = await self._read_and_process_csv(file.path)
|
||||
items = await self._read_and_process_csv(db, file.path)
|
||||
|
||||
# Prepare template data
|
||||
template_data = {
|
||||
@ -79,8 +80,51 @@ class PullSheetService(BaseService):
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating pull sheet PDF: {str(e)}")
|
||||
raise
|
||||
|
||||
async def _get_color_identity(self, db: Session, row: pd.Series) -> str:
|
||||
"""Get color identity from a row.
|
||||
|
||||
Args:
|
||||
row: pandas Series
|
||||
"""
|
||||
# get category id from set name
|
||||
group_id = db.query(TCGPlayerGroup).filter(TCGPlayerGroup.name == row['Set']).first().group_id
|
||||
# format number
|
||||
number = str(int(row['Number'])) if 'Number' in row and pd.notna(row['Number']) and '/' not in str(row['Number']) else str(row['Number']) if 'Number' in row and pd.notna(row['Number']) and '/' in str(row['Number']) else ''
|
||||
# get product info from category id
|
||||
product_id = db.query(TCGPlayerProduct).filter(TCGPlayerProduct.group_id == group_id).filter(TCGPlayerProduct.name == row['Product Name']).filter(TCGPlayerProduct.ext_number == number).filter(TCGPlayerProduct.ext_rarity == row['Rarity']).first().tcgplayer_product_id
|
||||
# get scryfall id from product id
|
||||
mtgjson_id = db.query(MTGJSONSKU).filter(MTGJSONSKU.tcgplayer_product_id == product_id).first().mtgjson_uuid
|
||||
scryfall_id = db.query(MTGJSONCard).filter(MTGJSONCard.mtgjson_uuid == mtgjson_id).first().scryfall_id
|
||||
# get color identity from scryfall
|
||||
scryfall_service = self.get_service('scryfall')
|
||||
color_identity = await scryfall_service.get_color_identity(scryfall_id)
|
||||
if color_identity is None:
|
||||
return '?'
|
||||
# color identity is str of json array, convert to human readable string of list
|
||||
color_identity = [str(color) for color in color_identity]
|
||||
# if color identity is empty, return C for colorless
|
||||
if not color_identity:
|
||||
return 'C'
|
||||
# ensure order, W > U > B > R > G
|
||||
color_identity = sorted(color_identity, key=lambda x: ['W', 'U', 'B', 'R', 'G'].index(x))
|
||||
color_identity = ''.join(color_identity)
|
||||
return color_identity
|
||||
|
||||
async def _update_row_color_identity(self, db: Session, row: pd.Series) -> pd.Series:
|
||||
"""Update color identity from a row.
|
||||
|
||||
Args:
|
||||
row: pandas Series
|
||||
"""
|
||||
# get color identity from row
|
||||
color_identity = await self._get_color_identity(db, row)
|
||||
# update row with color identity
|
||||
row['Color Identity'] = color_identity
|
||||
return row
|
||||
|
||||
|
||||
async def _read_and_process_csv(self, csv_path: str) -> List[Dict]:
|
||||
async def _read_and_process_csv(self, db: Session, csv_path: str) -> List[Dict]:
|
||||
"""Read and process CSV data using pandas.
|
||||
|
||||
Args:
|
||||
@ -103,6 +147,15 @@ class PullSheetService(BaseService):
|
||||
|
||||
# Sort by Set Release Date (descending) and then Product Name (ascending)
|
||||
df = df.sort_values(['Set Release Date', 'Set', 'Product Name'], ascending=[False, True, True])
|
||||
|
||||
# Process color identities for all rows
|
||||
color_identities = []
|
||||
for _, row in df.iterrows():
|
||||
color_identity = await self._get_color_identity(db, row)
|
||||
color_identities.append(color_identity)
|
||||
|
||||
# Add color identity column to dataframe
|
||||
df['Color Identity'] = color_identities
|
||||
|
||||
# Convert to list of dictionaries
|
||||
items = []
|
||||
@ -113,7 +166,8 @@ class PullSheetService(BaseService):
|
||||
'quantity': str(int(row['Quantity'])), # Convert to string for template
|
||||
'set': row['Set'],
|
||||
'rarity': row['Rarity'],
|
||||
'card_number': str(int(row['Number'])) if 'Number' in row else ''
|
||||
'card_number': str(int(row['Number'])) if 'Number' in row and pd.notna(row['Number']) and '/' not in str(row['Number']) else str(row['Number']) if 'Number' in row and pd.notna(row['Number']) and '/' in str(row['Number']) else '',
|
||||
'color_identity': row['Color Identity']
|
||||
})
|
||||
|
||||
return items
|
@ -1,6 +1,7 @@
|
||||
from typing import Callable, Dict, Any
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
import logging
|
||||
from apscheduler.schedulers.base import SchedulerNotRunningError
|
||||
|
||||
@ -16,26 +17,41 @@ class BaseScheduler:
|
||||
self,
|
||||
task_name: str,
|
||||
func: Callable,
|
||||
interval_seconds: int,
|
||||
interval_seconds: int = None,
|
||||
cron_expression: str = None,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> None:
|
||||
"""Schedule a task to run at regular intervals using APScheduler"""
|
||||
if task_name in self.jobs:
|
||||
logger.warning(f"Task {task_name} already exists. Removing existing job.")
|
||||
self.jobs[task_name].remove()
|
||||
|
||||
if interval_seconds and cron_expression:
|
||||
raise ValueError("Cannot specify both interval_seconds and cron_expression")
|
||||
elif not interval_seconds and not cron_expression:
|
||||
raise ValueError("Must specify either interval_seconds or cron_expression")
|
||||
|
||||
if interval_seconds:
|
||||
trigger = IntervalTrigger(seconds=interval_seconds)
|
||||
else:
|
||||
trigger = CronTrigger.from_crontab(cron_expression)
|
||||
|
||||
job = self.scheduler.add_job(
|
||||
func,
|
||||
trigger=IntervalTrigger(seconds=interval_seconds),
|
||||
func=func,
|
||||
trigger=trigger,
|
||||
args=args,
|
||||
kwargs=kwargs,
|
||||
id=task_name,
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
|
||||
self.jobs[task_name] = job
|
||||
logger.info(f"Scheduled task {task_name} to run every {interval_seconds} seconds")
|
||||
|
||||
if interval_seconds:
|
||||
logger.info(f"Scheduled task {task_name} to run every {interval_seconds} seconds")
|
||||
else:
|
||||
logger.info(f"Scheduled task {task_name} with cron expression: {cron_expression}")
|
||||
|
||||
|
||||
def remove_task(self, task_name: str) -> None:
|
||||
"""Remove a scheduled task"""
|
||||
|
@ -1,27 +1,22 @@
|
||||
from app.db.database import transaction, get_db
|
||||
from app.db.database import transaction
|
||||
from app.services.scheduler.base_scheduler import BaseScheduler
|
||||
from app.services.base_service import BaseService
|
||||
from sqlalchemy import text
|
||||
import logging
|
||||
|
||||
from app.models.tcgplayer_inventory import UnmanagedTCGPlayerInventory, TCGPlayerInventory
|
||||
from datetime import datetime
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SchedulerService:
|
||||
class SchedulerService(BaseService):
|
||||
def __init__(self):
|
||||
# Initialize BaseService with None as model since this service doesn't have a specific model
|
||||
super().__init__(None)
|
||||
self.scheduler = BaseScheduler()
|
||||
# Service manager will be set during initialization
|
||||
self._service_manager = None
|
||||
|
||||
@property
|
||||
def service_manager(self):
|
||||
if self._service_manager is None:
|
||||
from app.services.service_manager import ServiceManager
|
||||
self._service_manager = ServiceManager()
|
||||
return self._service_manager
|
||||
|
||||
async def update_open_orders_hourly(self):
|
||||
async def update_open_orders_hourly(self, db):
|
||||
"""
|
||||
Hourly update of orders from TCGPlayer API to database
|
||||
"""
|
||||
db = next(get_db())
|
||||
try:
|
||||
logger.info("Starting hourly order update")
|
||||
# Get order management service
|
||||
@ -39,14 +34,11 @@ class SchedulerService:
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating open orders: {str(e)}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def update_all_orders_daily(self):
|
||||
async def update_all_orders_daily(self, db):
|
||||
"""
|
||||
Daily update of all orders from TCGPlayer API to database
|
||||
"""
|
||||
db = next(get_db())
|
||||
try:
|
||||
logger.info("Starting daily order update")
|
||||
# Get order management service
|
||||
@ -64,23 +56,48 @@ class SchedulerService:
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating all orders: {str(e)}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def refresh_tcgplayer_inventory_table(self, db):
|
||||
"""
|
||||
Refresh the TCGPlayer inventory table
|
||||
"""
|
||||
tcgplayer_inventory_service = self.service_manager.get_service('tcgplayer_inventory')
|
||||
with transaction(db):
|
||||
db.query(UnmanagedTCGPlayerInventory).delete()
|
||||
db.query(TCGPlayerInventory).delete()
|
||||
db.flush()
|
||||
await tcgplayer_inventory_service.refresh_tcgplayer_inventory_table(db)
|
||||
db.flush()
|
||||
|
||||
async def start_scheduled_tasks(self):
|
||||
async def start_scheduled_tasks(self, db):
|
||||
"""Start all scheduled tasks"""
|
||||
# Schedule open orders update to run hourly at 00 minutes
|
||||
await self.scheduler.schedule_task(
|
||||
task_name="update_open_orders_hourly",
|
||||
func=self.update_open_orders_hourly,
|
||||
interval_seconds=60 * 60, # 1 hour
|
||||
cron_expression="*/10 * * * *", # Run at minute 10 of every hour
|
||||
db=db
|
||||
)
|
||||
# Schedule all orders update to run daily at 1 AM
|
||||
# Schedule all orders update to run daily at 3 AM
|
||||
await self.scheduler.schedule_task(
|
||||
task_name="update_all_orders_daily",
|
||||
func=self.update_all_orders_daily,
|
||||
interval_seconds=24 * 60 * 60, # 24 hours
|
||||
cron_expression="0 3 * * *", # Run at 3:00 AM every day
|
||||
db=db
|
||||
)
|
||||
# Schedule TCGPlayer inventory refresh to run every 3 hours
|
||||
await self.scheduler.schedule_task(
|
||||
task_name="refresh_tcgplayer_inventory_table",
|
||||
func=self.refresh_tcgplayer_inventory_table,
|
||||
cron_expression="28 */3 * * *", # Run at minute 28 of every 3rd hour
|
||||
db=db
|
||||
)
|
||||
|
||||
# Run initial inventory refresh on startup if inventory update was not run today
|
||||
# get last inventory update date
|
||||
last_inventory_update = db.query(TCGPlayerInventory).order_by(TCGPlayerInventory.created_at.desc()).first()
|
||||
if last_inventory_update is None or last_inventory_update.created_at.date() != datetime.now().date():
|
||||
await self.refresh_tcgplayer_inventory_table(db)
|
||||
|
||||
self.scheduler.start()
|
||||
logger.info("All scheduled tasks started")
|
||||
|
@ -26,7 +26,17 @@ class ServiceManager:
|
||||
'set_label': 'app.services.set_label_service.SetLabelService',
|
||||
'data_initialization': 'app.services.data_initialization.DataInitializationService',
|
||||
'scheduler': 'app.services.scheduler.scheduler_service.SchedulerService',
|
||||
'file': 'app.services.file_service.FileService'
|
||||
'file': 'app.services.file_service.FileService',
|
||||
'tcgcsv': 'app.services.external_api.tcgcsv.tcgcsv_service.TCGCSVService',
|
||||
'mtgjson': 'app.services.external_api.mtgjson.mtgjson_service.MTGJSONService',
|
||||
'manabox': 'app.services.manabox_service.ManaboxService',
|
||||
'pricing': 'app.services.pricing_service.PricingService',
|
||||
'inventory': 'app.services.inventory_service.InventoryService',
|
||||
'box': 'app.services.inventory_service.BoxService',
|
||||
'case': 'app.services.inventory_service.CaseService',
|
||||
'marketplace_listing': 'app.services.inventory_service.MarketplaceListingService',
|
||||
'scryfall': 'app.services.external_api.scryfall.scryfall_service.ScryfallService'
|
||||
|
||||
}
|
||||
self._service_configs = {
|
||||
'label_printer': {'printer_api_url': "http://192.168.1.110:8000"},
|
||||
|
@ -10,7 +10,7 @@ import aiohttp
|
||||
import jinja2
|
||||
from weasyprint import HTML
|
||||
from app.services.base_service import BaseService
|
||||
from app.models.tcgplayer_group import TCGPlayerGroup
|
||||
from app.models.tcgplayer_products import TCGPlayerProduct, TCGPlayerGroup
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
@ -67,16 +67,30 @@ function displayOrders(orders) {
|
||||
}
|
||||
|
||||
orders.forEach(order => {
|
||||
const hasHighQuantity = order.itemQuantity > 9;
|
||||
const hasHighAmount = order.productAmount > 40.00;
|
||||
|
||||
const orderCard = document.createElement('div');
|
||||
orderCard.className = `bg-gray-700 rounded-lg shadow-sm p-4 border border-gray-600 hover:shadow-md transition-shadow cursor-pointer ${
|
||||
selectedOrders.has(order.orderNumber) ? 'ring-2 ring-blue-500' : ''
|
||||
}`;
|
||||
} ${hasHighQuantity || hasHighAmount ? 'border-yellow-500' : ''}`;
|
||||
orderCard.dataset.orderId = order.orderNumber;
|
||||
orderCard.innerHTML = `
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-bold text-blue-400 truncate">#${order.orderNumber || 'N/A'}</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-lg font-bold text-blue-400 truncate">
|
||||
<a href="https://sellerportal.tcgplayer.com/orders/${order.orderNumber}" target="_blank" rel="noopener noreferrer" class="hover:underline" onclick="event.stopPropagation()">${order.orderNumber || 'N/A'}</a>
|
||||
</h3>
|
||||
${(hasHighQuantity || hasHighAmount) ? `
|
||||
<span class="text-yellow-400" title="${hasHighQuantity ? 'High item quantity' : ''}${hasHighQuantity && hasHighAmount ? ' and ' : ''}${hasHighAmount ? 'High product amount' : ''}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">${order.buyerName || 'N/A'}</p>
|
||||
</div>
|
||||
<span class="px-2 py-1 text-xs rounded-full ${
|
||||
@ -86,8 +100,23 @@ function displayOrders(orders) {
|
||||
<div class="mt-auto">
|
||||
<div class="flex justify-between items-center">
|
||||
<p class="text-sm text-gray-400">${order.orderDate ? new Date(order.orderDate).toLocaleString() : 'N/A'}</p>
|
||||
<p class="text-lg font-bold text-white">$${order.totalAmount ? order.totalAmount.toFixed(2) : '0.00'}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
${hasHighAmount ? `
|
||||
<span class="text-yellow-400 text-sm">⚠️</span>
|
||||
` : ''}
|
||||
<p class="text-lg font-bold ${hasHighAmount ? 'text-yellow-400' : 'text-white'}">$${order.totalAmount ? order.totalAmount.toFixed(2) : '0.00'}</p>
|
||||
</div>
|
||||
</div>
|
||||
${hasHighQuantity ? `
|
||||
<div class="mt-2 text-sm text-yellow-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
High quantity: ${order.itemQuantity} items
|
||||
</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@ -228,6 +257,233 @@ async function generateAddressLabels() {
|
||||
}
|
||||
}
|
||||
|
||||
// Show return labels modal
|
||||
function showReturnLabelsModal() {
|
||||
const modal = document.getElementById('returnLabelsModal');
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
}
|
||||
|
||||
// Close return labels modal
|
||||
function closeReturnLabelsModal() {
|
||||
const modal = document.getElementById('returnLabelsModal');
|
||||
modal.classList.remove('flex');
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Submit return labels request
|
||||
async function submitReturnLabels() {
|
||||
try {
|
||||
const numberOfLabels = document.getElementById('numberOfLabels').value;
|
||||
if (!numberOfLabels || numberOfLabels < 1) {
|
||||
showToast('Please enter a valid number of labels', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_BASE_URL}/orders/generate-return-labels`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
number_of_labels: parseInt(numberOfLabels)
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || 'Failed to generate return labels');
|
||||
}
|
||||
|
||||
showToast('Return labels generated successfully');
|
||||
closeReturnLabelsModal();
|
||||
} catch (error) {
|
||||
showToast('Error generating return labels: ' + error.message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate return labels (opens modal)
|
||||
function generateReturnLabels() {
|
||||
showReturnLabelsModal();
|
||||
}
|
||||
|
||||
// Show Pirate Ship label modal
|
||||
function showPirateShipModal() {
|
||||
const modal = document.getElementById('pirateShipModal');
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
}
|
||||
|
||||
// Close Pirate Ship label modal
|
||||
function closePirateShipModal() {
|
||||
const modal = document.getElementById('pirateShipModal');
|
||||
modal.classList.remove('flex');
|
||||
modal.classList.add('hidden');
|
||||
// Reset file input
|
||||
document.getElementById('pirateShipFile').value = '';
|
||||
}
|
||||
|
||||
// Submit Pirate Ship label
|
||||
async function submitPirateShipLabel() {
|
||||
try {
|
||||
const fileInput = document.getElementById('pirateShipFile');
|
||||
const file = fileInput.files[0];
|
||||
|
||||
if (!file) {
|
||||
showToast('Please select a PDF file', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.type !== 'application/pdf') {
|
||||
showToast('Please select a valid PDF file', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/orders/print-pirate-ship-label`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || 'Failed to print Pirate Ship label');
|
||||
}
|
||||
|
||||
showToast('Pirate Ship label printed successfully');
|
||||
closePirateShipModal();
|
||||
} catch (error) {
|
||||
showToast('Error printing Pirate Ship label: ' + error.message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Show set labels modal
|
||||
function showSetLabelsModal() {
|
||||
const modal = document.getElementById('setLabelsModal');
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
fetchAvailableSets();
|
||||
|
||||
// Add event listener for search input
|
||||
const searchInput = document.getElementById('setSearch');
|
||||
searchInput.addEventListener('input', filterSets);
|
||||
}
|
||||
|
||||
// Close set labels modal
|
||||
function closeSetLabelsModal() {
|
||||
const modal = document.getElementById('setLabelsModal');
|
||||
modal.classList.remove('flex');
|
||||
modal.classList.add('hidden');
|
||||
|
||||
// Clear search input
|
||||
document.getElementById('setSearch').value = '';
|
||||
}
|
||||
|
||||
// Filter sets based on search input
|
||||
function filterSets() {
|
||||
const searchTerm = document.getElementById('setSearch').value.toLowerCase();
|
||||
const setItems = document.querySelectorAll('#setLabelsList > div');
|
||||
|
||||
setItems.forEach(item => {
|
||||
const label = item.querySelector('label');
|
||||
const text = label.textContent.toLowerCase();
|
||||
if (text.includes(searchTerm)) {
|
||||
item.style.display = 'flex';
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch available sets from the API
|
||||
async function fetchAvailableSets() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_BASE_URL}/set-labels/available-sets`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch available sets');
|
||||
}
|
||||
|
||||
const sets = await response.json();
|
||||
displayAvailableSets(sets);
|
||||
} catch (error) {
|
||||
showToast('Error fetching available sets: ' + error.message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Display available sets in the modal
|
||||
function displayAvailableSets(sets) {
|
||||
const setList = document.getElementById('setLabelsList');
|
||||
setList.innerHTML = '';
|
||||
|
||||
if (!sets || sets.length === 0) {
|
||||
setList.innerHTML = '<div class="text-center text-gray-400 py-4">No sets available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort sets alphabetically by name
|
||||
sets.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
sets.forEach(set => {
|
||||
const setItem = document.createElement('div');
|
||||
setItem.className = 'flex items-center p-2 hover:bg-gray-600 rounded-lg cursor-pointer';
|
||||
setItem.innerHTML = `
|
||||
<input type="checkbox" id="set-${set.code}" class="rounded border-gray-600 bg-gray-800 text-teal-600 focus:ring-teal-500">
|
||||
<label for="set-${set.code}" class="ml-2 text-gray-300">${set.name} (${set.code})</label>
|
||||
`;
|
||||
setList.appendChild(setItem);
|
||||
});
|
||||
|
||||
// Trigger initial filter in case there's text in the search box
|
||||
filterSets();
|
||||
}
|
||||
|
||||
// Submit set labels request
|
||||
async function submitSetLabels() {
|
||||
try {
|
||||
const selectedSets = Array.from(document.querySelectorAll('#setLabelsList input[type="checkbox"]:checked'))
|
||||
.map(checkbox => checkbox.id.replace('set-', ''));
|
||||
|
||||
if (selectedSets.length === 0) {
|
||||
showToast('Please select at least one set', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_BASE_URL}/set-labels/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sets: selectedSets
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || 'Failed to generate set labels');
|
||||
}
|
||||
|
||||
showToast('Set labels generated successfully');
|
||||
closeSetLabelsModal();
|
||||
} catch (error) {
|
||||
showToast('Error generating set labels: ' + error.message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Load orders when page loads
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
fetchOrders();
|
||||
|
@ -20,6 +20,22 @@
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-gray-900 min-h-screen text-gray-100">
|
||||
<!-- Navigation Menu -->
|
||||
<nav class="bg-gray-800 shadow-sm">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<div class="flex items-center">
|
||||
<a href="/" class="text-xl font-bold text-gray-100">TCGPlayer Manager</a>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700">Orders</a>
|
||||
<a href="/manabox.html" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700">Manabox</a>
|
||||
<a href="/transactions.html" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700">Transactions</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="bg-gray-800 rounded-xl shadow-sm p-6 mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-100 mb-2">TCGPlayer Order Management</h1>
|
||||
@ -39,6 +55,15 @@
|
||||
<button onclick="generateAddressLabels()" class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition-colors">
|
||||
Generate Address Labels
|
||||
</button>
|
||||
<button onclick="generateReturnLabels()" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition-colors">
|
||||
Generate Return Labels
|
||||
</button>
|
||||
<button onclick="showPirateShipModal()" class="px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2 transition-colors">
|
||||
Upload Pirate Ship Label
|
||||
</button>
|
||||
<button onclick="showSetLabelsModal()" class="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 transition-colors">
|
||||
Generate Set Labels
|
||||
</button>
|
||||
</div>
|
||||
<div id="labelOptions" class="bg-gray-700 rounded-lg p-4">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Label Type</label>
|
||||
@ -49,6 +74,69 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Return Labels Modal -->
|
||||
<div id="returnLabelsModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center">
|
||||
<div class="bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-xl font-semibold text-gray-100 mb-4">Generate Return Labels</h3>
|
||||
<div class="mb-4">
|
||||
<label for="numberOfLabels" class="block text-sm font-medium text-gray-300 mb-2">Number of Labels</label>
|
||||
<input type="number" id="numberOfLabels" min="1" value="1" class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button onclick="closeReturnLabelsModal()" class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button onclick="submitReturnLabels()" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition-colors">
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pirate Ship Label Modal -->
|
||||
<div id="pirateShipModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center">
|
||||
<div class="bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-xl font-semibold text-gray-100 mb-4">Upload Pirate Ship Label</h3>
|
||||
<div class="mb-4">
|
||||
<label for="pirateShipFile" class="block text-sm font-medium text-gray-300 mb-2">Select PDF File</label>
|
||||
<input type="file" id="pirateShipFile" accept=".pdf" class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button onclick="closePirateShipModal()" class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button onclick="submitPirateShipLabel()" class="px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2 transition-colors">
|
||||
Upload & Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Set Labels Modal -->
|
||||
<div id="setLabelsModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center">
|
||||
<div class="bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-xl font-semibold text-gray-100 mb-4">Generate Set Labels</h3>
|
||||
<div class="mb-4">
|
||||
<div class="mb-2">
|
||||
<label for="setSearch" class="block text-sm font-medium text-gray-300 mb-2">Search Sets</label>
|
||||
<input type="text" id="setSearch" placeholder="Search sets..." class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Select Sets</label>
|
||||
<div id="setLabelsList" class="max-h-60 overflow-y-auto bg-gray-700 rounded-lg p-2">
|
||||
<!-- Sets will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button onclick="closeSetLabelsModal()" class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button onclick="submitSetLabels()" class="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 transition-colors">
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order List Section -->
|
||||
<div class="bg-gray-800 rounded-xl shadow-sm p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
|
86
app/static/manabox.html
Normal file
86
app/static/manabox.html
Normal file
@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Manabox Inventory Management</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-gray-900 min-h-screen text-gray-100">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="bg-gray-800 rounded-xl shadow-sm p-6 mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-100 mb-2">Manabox Inventory Management</h1>
|
||||
<p class="text-gray-400">Upload and manage your Manabox inventory</p>
|
||||
</div>
|
||||
|
||||
<!-- File Upload Section -->
|
||||
<div class="bg-gray-800 rounded-xl shadow-sm p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-100 mb-6">Upload Manabox CSV</h2>
|
||||
<form id="uploadForm" class="space-y-4">
|
||||
<div>
|
||||
<label for="source" class="block text-sm font-medium text-gray-300 mb-2">Source</label>
|
||||
<input type="text" id="source" name="source" required
|
||||
class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-300 mb-2">Description</label>
|
||||
<textarea id="description" name="description" rows="3" required
|
||||
class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="csvFile" class="block text-sm font-medium text-gray-300 mb-2">CSV File</label>
|
||||
<input type="file" id="csvFile" name="file" accept=".csv" required
|
||||
class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
|
||||
Upload CSV
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- File Uploads List Section -->
|
||||
<div class="bg-gray-800 rounded-xl shadow-sm p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-100">Recent Uploads</h2>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button onclick="selectAllUploads()" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
|
||||
Select All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
|
||||
<input type="checkbox" id="selectAll" class="rounded border-gray-600 bg-gray-800 text-blue-600 focus:ring-blue-500">
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Source</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Description</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Upload Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="uploadsList" class="divide-y divide-gray-700">
|
||||
<!-- Uploads will be populated here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/manabox.js"></script>
|
||||
</body>
|
||||
</html>
|
170
app/static/manabox.js
Normal file
170
app/static/manabox.js
Normal file
@ -0,0 +1,170 @@
|
||||
// API base URL
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
// Selected uploads for actions
|
||||
let selectedUploads = new Set();
|
||||
|
||||
// Show toast notification
|
||||
function showToast(message, type = 'success') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `fixed bottom-4 right-4 px-6 py-3 rounded-lg shadow-lg text-white ${
|
||||
type === 'success' ? 'bg-green-600' : 'bg-red-600'
|
||||
} transform translate-y-0 opacity-100 transition-all duration-300`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateY(100%)';
|
||||
toast.style.opacity = '0';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
function setLoading(isLoading) {
|
||||
const buttons = document.querySelectorAll('button');
|
||||
buttons.forEach(button => {
|
||||
if (isLoading) {
|
||||
button.disabled = true;
|
||||
button.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
} else {
|
||||
button.disabled = false;
|
||||
button.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', document.getElementById('csvFile').files[0]);
|
||||
formData.append('source', document.getElementById('source').value);
|
||||
formData.append('description', document.getElementById('description').value);
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_BASE_URL}/manabox/process-csv`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || 'Failed to upload CSV');
|
||||
}
|
||||
|
||||
showToast('CSV uploaded successfully');
|
||||
document.getElementById('uploadForm').reset();
|
||||
fetchUploads(); // Refresh the uploads list
|
||||
} catch (error) {
|
||||
showToast('Error uploading CSV: ' + error.message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch uploads from the API
|
||||
async function fetchUploads() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_BASE_URL}/manabox/manabox-file-uploads`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch uploads');
|
||||
}
|
||||
|
||||
const uploads = await response.json();
|
||||
displayUploads(uploads);
|
||||
} catch (error) {
|
||||
showToast('Error fetching uploads: ' + error.message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Display uploads in the UI
|
||||
function displayUploads(uploads) {
|
||||
const uploadsList = document.getElementById('uploadsList');
|
||||
uploadsList.innerHTML = '';
|
||||
|
||||
if (!uploads || uploads.length === 0) {
|
||||
uploadsList.innerHTML = '<tr><td colspan="5" class="px-6 py-4 text-center text-gray-400">No uploads found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
uploads.forEach(upload => {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'hover:bg-gray-700';
|
||||
row.dataset.uploadId = upload.id;
|
||||
row.innerHTML = `
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<input type="checkbox" class="upload-checkbox rounded border-gray-600 bg-gray-800 text-blue-600 focus:ring-blue-500">
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300">${upload.name || 'N/A'}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-300">${upload.file_metadata?.description || 'N/A'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300">${formatDate(upload.created_at)}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs rounded-full bg-green-900/50 text-green-300">Processed</span>
|
||||
</td>
|
||||
`;
|
||||
uploadsList.appendChild(row);
|
||||
|
||||
// Add click event listener to the checkbox
|
||||
const checkbox = row.querySelector('.upload-checkbox');
|
||||
checkbox.addEventListener('change', () => {
|
||||
const uploadId = row.dataset.uploadId;
|
||||
if (checkbox.checked) {
|
||||
selectedUploads.add(uploadId);
|
||||
} else {
|
||||
selectedUploads.delete(uploadId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to format date
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// Select all uploads
|
||||
function selectAllUploads() {
|
||||
const checkboxes = document.querySelectorAll('.upload-checkbox');
|
||||
const allSelected = checkboxes.length > 0 && Array.from(checkboxes).every(checkbox => checkbox.checked);
|
||||
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = !allSelected;
|
||||
const row = checkbox.closest('tr');
|
||||
const uploadId = row.dataset.uploadId;
|
||||
if (!allSelected) {
|
||||
selectedUploads.add(uploadId);
|
||||
} else {
|
||||
selectedUploads.delete(uploadId);
|
||||
}
|
||||
});
|
||||
|
||||
showToast(allSelected ? 'All uploads deselected' : 'All uploads selected');
|
||||
}
|
||||
|
||||
// Initialize the page
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
fetchUploads();
|
||||
|
||||
// Add event listener for the select all checkbox
|
||||
document.getElementById('selectAll').addEventListener('change', (e) => {
|
||||
const checkboxes = document.querySelectorAll('.upload-checkbox');
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = e.target.checked;
|
||||
const row = checkbox.closest('tr');
|
||||
const uploadId = row.dataset.uploadId;
|
||||
if (e.target.checked) {
|
||||
selectedUploads.add(uploadId);
|
||||
} else {
|
||||
selectedUploads.delete(uploadId);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
@ -105,4 +105,126 @@ button:hover {
|
||||
select, button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Transaction Page Styles */
|
||||
.transaction-form {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.transaction-form .form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.transaction-form label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.transaction-form .form-control {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.transaction-form .btn-add {
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.transaction-form .items-section {
|
||||
margin-top: 2rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.transaction-list {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.transaction-card {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.transaction-card h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.transaction-card p {
|
||||
margin-bottom: 0.25rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-content {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
border-top: 1px solid #dee2e6;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Dark Mode Support */
|
||||
body.dark-mode {
|
||||
background-color: #1a1a1a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
body.dark-mode .container {
|
||||
background-color: #2d2d2d;
|
||||
}
|
||||
|
||||
body.dark-mode .transaction-card {
|
||||
background-color: #2d2d2d;
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
body.dark-mode .transaction-card h3 {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
body.dark-mode .transaction-card p {
|
||||
color: #b3b3b3;
|
||||
}
|
||||
|
||||
body.dark-mode .modal-content {
|
||||
background-color: #2d2d2d;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
body.dark-mode .modal-header,
|
||||
body.dark-mode .modal-footer {
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control {
|
||||
background-color: #404040;
|
||||
border-color: #505050;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
body.dark-mode .form-control:focus {
|
||||
background-color: #404040;
|
||||
border-color: #007bff;
|
||||
color: #ffffff;
|
||||
}
|
184
app/static/transactions.html
Normal file
184
app/static/transactions.html
Normal file
@ -0,0 +1,184 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Transactions - AI Giga TCG</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-gray-900 min-h-screen text-gray-100">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="bg-gray-800 rounded-xl shadow-sm p-6 mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-100 mb-2">Transactions</h1>
|
||||
<p class="text-gray-400">Manage your transactions</p>
|
||||
</div>
|
||||
|
||||
<!-- Create Transaction Button -->
|
||||
<div class="bg-gray-800 rounded-xl shadow-sm p-6 mb-8">
|
||||
<div class="flex space-x-4">
|
||||
<button id="createTransactionBtn" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
|
||||
Create New Transaction
|
||||
</button>
|
||||
<button id="downloadTcgplayerUpdateBtn" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 transition-colors">
|
||||
Download TCGPlayer Update File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transaction List -->
|
||||
<div id="transactionList" class="bg-gray-800 rounded-xl shadow-sm p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold text-gray-100">Recent Transactions</h2>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<label for="limitSelect" class="text-sm text-gray-300">Show:</label>
|
||||
<select id="limitSelect" class="rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="transactionsTable" class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Type</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Vendor/Customer</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Total</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-700" id="transactionsBody">
|
||||
<!-- Transactions will be loaded here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Pagination Controls -->
|
||||
<div class="flex justify-between items-center mt-4">
|
||||
<button id="prevPageBtn" class="px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Previous
|
||||
</button>
|
||||
<span id="pageInfo" class="text-gray-300">Page 1</span>
|
||||
<button id="nextPageBtn" class="px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Transaction Modal -->
|
||||
<div id="createTransactionModal" class="fixed inset-0 bg-black bg-opacity-50 hidden flex items-center justify-center">
|
||||
<div class="bg-gray-800 rounded-lg p-6 max-w-2xl w-full mx-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-gray-100">Create Transaction</h3>
|
||||
<button onclick="closeTransactionModal()" class="text-gray-400 hover:text-white">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form id="transactionForm" class="space-y-4">
|
||||
<!-- Transaction Type -->
|
||||
<div>
|
||||
<label for="transactionType" class="block text-sm font-medium text-gray-300 mb-2">Transaction Type</label>
|
||||
<select id="transactionType" class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="purchase" selected>Purchase</option>
|
||||
<option value="sale">Sale</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Vendor/Customer Selection -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label for="vendorSelect" class="block text-sm font-medium text-gray-300">Vendor</label>
|
||||
<button type="button" id="addVendorBtn" class="px-2 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
|
||||
Add New
|
||||
</button>
|
||||
</div>
|
||||
<select id="vendorSelect" class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">Select a vendor</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Marketplace Selection (for sales) -->
|
||||
<div id="marketplaceSection" class="hidden">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label for="marketplaceSelect" class="block text-sm font-medium text-gray-300">Marketplace</label>
|
||||
<button type="button" id="addMarketplaceBtn" class="px-2 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
|
||||
Add New
|
||||
</button>
|
||||
</div>
|
||||
<select id="marketplaceSelect" class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">Select a marketplace</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Transaction Date -->
|
||||
<div>
|
||||
<label for="transactionDate" class="block text-sm font-medium text-gray-300 mb-2">Transaction Date</label>
|
||||
<input type="datetime-local" id="transactionDate" class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500" required>
|
||||
</div>
|
||||
|
||||
<!-- Transaction Notes -->
|
||||
<div>
|
||||
<label for="transactionNotes" class="block text-sm font-medium text-gray-300 mb-2">Notes</label>
|
||||
<textarea id="transactionNotes" class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Items Section -->
|
||||
<div id="itemsSection" class="border border-gray-700 rounded-lg p-4">
|
||||
<h5 class="text-lg font-medium text-gray-100 mb-4">Items</h5>
|
||||
<div id="itemsContainer" class="space-y-4">
|
||||
<!-- Items will be added here -->
|
||||
</div>
|
||||
<button type="button" id="addItemBtn" class="mt-4 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 transition-colors">
|
||||
Add Item
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
<button onclick="closeTransactionModal()" class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button id="saveTransactionBtn" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
|
||||
Save Transaction
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transaction Details Modal -->
|
||||
<div id="transactionDetailsModal" class="fixed inset-0 bg-black bg-opacity-50 hidden flex items-center justify-center">
|
||||
<div class="bg-gray-800 rounded-lg p-6 max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-semibold text-gray-100">Transaction Details</h3>
|
||||
<button onclick="closeTransactionDetailsModal()" class="text-gray-400 hover:text-white">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="transactionDetails" class="space-y-4">
|
||||
<!-- Transaction details will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="transactions.js"></script>
|
||||
</body>
|
||||
</html>
|
1113
app/static/transactions.js
Normal file
1113
app/static/transactions.js
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1 +0,0 @@
|
||||
{"success": true, "errors": [], "results": [{"productId": 38444, "lowPrice": 154.95, "midPrice": 223.55, "highPrice": 275.98, "marketPrice": 218.59, "directLowPrice": null, "subTypeName": "Foil"}, {"productId": 38445, "lowPrice": 349.49, "midPrice": 374.75, "highPrice": 400.0, "marketPrice": 385.0, "directLowPrice": null, "subTypeName": "Foil"}, {"productId": 38446, "lowPrice": 97.71, "midPrice": 120.99, "highPrice": 255.61, "marketPrice": 258.98, "directLowPrice": null, "subTypeName": "Foil"}, {"productId": 38447, "lowPrice": 80.0, "midPrice": 109.99, "highPrice": 214.45, "marketPrice": 99.92, "directLowPrice": 94.98, "subTypeName": "Foil"}, {"productId": 57653, "lowPrice": 71.99, "midPrice": 75.99, "highPrice": 100.0, "marketPrice": 69.44, "directLowPrice": null, "subTypeName": "Foil"}, {"productId": 67401, "lowPrice": 28.01, "midPrice": 78.72, "highPrice": 119.99, "marketPrice": 33.73, "directLowPrice": 114.99, "subTypeName": "Foil"}, {"productId": 71898, "lowPrice": 167.25, "midPrice": 183.34, "highPrice": 223.35, "marketPrice": 167.25, "directLowPrice": 168.28, "subTypeName": "Foil"}, {"productId": 78237, "lowPrice": 36.0, "midPrice": 89.24, "highPrice": 159.91, "marketPrice": 47.42, "directLowPrice": 59.99, "subTypeName": "Foil"}, {"productId": 95046, "lowPrice": 43.0, "midPrice": 62.92, "highPrice": 97.57, "marketPrice": 64.98, "directLowPrice": 44.0, "subTypeName": "Foil"}, {"productId": 110267, "lowPrice": 23.75, "midPrice": 31.21, "highPrice": 48.4, "marketPrice": 27.89, "directLowPrice": null, "subTypeName": "Foil"}, {"productId": 125324, "lowPrice": 18.0, "midPrice": 25.99, "highPrice": 53.45, "marketPrice": 25.86, "directLowPrice": 28.58, "subTypeName": "Foil"}, {"productId": 154792, "lowPrice": 16.73, "midPrice": 25.43, "highPrice": 49.0, "marketPrice": 17.47, "directLowPrice": null, "subTypeName": "Foil"}, {"productId": 181788, "lowPrice": 23.57, "midPrice": 27.83, "highPrice": 51.0, "marketPrice": 23.83, "directLowPrice": null, "subTypeName": "Foil"}, {"productId": 205180, "lowPrice": 16.99, "midPrice": 29.99, "highPrice": 70.2, "marketPrice": 18.3, "directLowPrice": 70.58, "subTypeName": "Foil"}, {"productId": 228752, "lowPrice": 24.99, "midPrice": 29.98, "highPrice": 224.99, "marketPrice": 29.43, "directLowPrice": null, "subTypeName": "Foil"}, {"productId": 257182, "lowPrice": 30.6, "midPrice": 75.0, "highPrice": 141.0, "marketPrice": 30.6, "directLowPrice": null, "subTypeName": "Foil"}, {"productId": 455848, "lowPrice": 15.0, "midPrice": 27.98, "highPrice": 134.0, "marketPrice": 20.73, "directLowPrice": null, "subTypeName": "Foil"}, {"productId": 527738, "lowPrice": 14.69, "midPrice": 19.31, "highPrice": 80.0, "marketPrice": 16.28, "directLowPrice": null, "subTypeName": "Foil"}, {"productId": 600517, "lowPrice": 47.97, "midPrice": 61.22, "highPrice": 75.0, "marketPrice": 61.22, "directLowPrice": null, "subTypeName": "Foil"}]}
|
File diff suppressed because one or more lines are too long
@ -1 +0,0 @@
|
||||
{"success": true, "errors": [], "results": [{"productId": 21705, "lowPrice": 0.18, "midPrice": 0.57, "highPrice": 29.99, "marketPrice": 0.56, "directLowPrice": 0.43, "subTypeName": "Foil"}, {"productId": 37765, "lowPrice": 0.1, "midPrice": 0.25, "highPrice": 1.6, "marketPrice": 0.14, "directLowPrice": 0.18, "subTypeName": "Normal"}, {"productId": 37767, "lowPrice": 0.18, "midPrice": 0.41, "highPrice": 4.99, "marketPrice": 0.25, "directLowPrice": 0.6, "subTypeName": "Normal"}, {"productId": 37772, "lowPrice": 0.14, "midPrice": 0.39, "highPrice": 4.99, "marketPrice": 0.15, "directLowPrice": 0.17, "subTypeName": "Normal"}, {"productId": 37773, "lowPrice": 0.05, "midPrice": 0.24, "highPrice": 3.99, "marketPrice": 0.12, "directLowPrice": 0.07, "subTypeName": "Normal"}, {"productId": 37778, "lowPrice": 0.09, "midPrice": 0.25, "highPrice": 3.13, "marketPrice": 0.22, "directLowPrice": 0.18, "subTypeName": "Normal"}, {"productId": 37780, "lowPrice": 0.04, "midPrice": 0.37, "highPrice": 4.99, "marketPrice": 0.13, "directLowPrice": 0.19, "subTypeName": "Normal"}, {"productId": 37784, "lowPrice": 0.15, "midPrice": 0.3, "highPrice": 4.99, "marketPrice": 0.22, "directLowPrice": 0.2, "subTypeName": "Normal"}, {"productId": 37785, "lowPrice": 0.15, "midPrice": 0.37, "highPrice": 3.99, "marketPrice": 0.19, "directLowPrice": 0.09, "subTypeName": "Normal"}, {"productId": 37788, "lowPrice": 0.14, "midPrice": 0.3, "highPrice": 3.0, "marketPrice": 0.31, "directLowPrice": 0.2, "subTypeName": "Normal"}, {"productId": 37789, "lowPrice": 0.15, "midPrice": 0.35, "highPrice": 4.99, "marketPrice": 0.31, "directLowPrice": 0.15, "subTypeName": "Normal"}, {"productId": 37790, "lowPrice": 0.14, "midPrice": 0.39, "highPrice": 3.99, "marketPrice": 0.22, "directLowPrice": 0.14, "subTypeName": "Normal"}, {"productId": 37793, "lowPrice": 0.05, "midPrice": 0.25, "highPrice": 3.99, "marketPrice": 0.08, "directLowPrice": 0.08, "subTypeName": "Normal"}, {"productId": 37799, "lowPrice": 0.1, "midPrice": 0.25, "highPrice": 3.0, "marketPrice": 0.2, "directLowPrice": 0.19, "subTypeName": "Normal"}, {"productId": 37802, "lowPrice": 0.1, "midPrice": 0.25, "highPrice": 5.0, "marketPrice": 0.17, "directLowPrice": 0.1, "subTypeName": "Normal"}, {"productId": 37809, "lowPrice": 0.1, "midPrice": 0.25, "highPrice": 1.67, "marketPrice": 0.19, "directLowPrice": 0.07, "subTypeName": "Normal"}, {"productId": 37810, "lowPrice": 0.09, "midPrice": 0.35, "highPrice": 5.25, "marketPrice": 0.28, "directLowPrice": null, "subTypeName": "Normal"}, {"productId": 37812, "lowPrice": 0.18, "midPrice": 0.51, "highPrice": 4.99, "marketPrice": 0.83, "directLowPrice": 0.18, "subTypeName": "Normal"}, {"productId": 37813, "lowPrice": 0.15, "midPrice": 0.25, "highPrice": 2.99, "marketPrice": 0.22, "directLowPrice": 0.14, "subTypeName": "Normal"}, {"productId": 37814, "lowPrice": 0.14, "midPrice": 0.28, "highPrice": 1.76, "marketPrice": 0.18, "directLowPrice": null, "subTypeName": "Normal"}]}
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1 +0,0 @@
|
||||
{"success": true, "errors": [], "results": [{"productId": 70757, "lowPrice": 0.8, "midPrice": 2.48, "highPrice": 54.0, "marketPrice": 2.61, "directLowPrice": null, "subTypeName": "Foil"}, {"productId": 70758, "lowPrice": 1.14, "midPrice": 1.74, "highPrice": 12.88, "marketPrice": 1.37, "directLowPrice": null, "subTypeName": "Foil"}, {"productId": 70759, "lowPrice": 0.75, "midPrice": 2.05, "highPrice": 8.99, "marketPrice": 2.51, "directLowPrice": null, "subTypeName": "Foil"}, {"productId": 70760, "lowPrice": 0.79, "midPrice": 1.61, "highPrice": 6.0, "marketPrice": 1.17, "directLowPrice": 1.6, "subTypeName": "Foil"}, {"productId": 70761, "lowPrice": 8.0, "midPrice": 11.12, "highPrice": 49.95, "marketPrice": 11.01, "directLowPrice": 11.96, "subTypeName": "Foil"}, {"productId": 70762, "lowPrice": 0.66, "midPrice": 1.34, "highPrice": 3.99, "marketPrice": 1.22, "directLowPrice": null, "subTypeName": "Foil"}, {"productId": 70763, "lowPrice": 4.1, "midPrice": 6.0, "highPrice": 19.99, "marketPrice": 5.09, "directLowPrice": null, "subTypeName": "Foil"}, {"productId": 70764, "lowPrice": 15.53, "midPrice": 19.96, "highPrice": 159.99, "marketPrice": 19.05, "directLowPrice": 21.69, "subTypeName": "Foil"}, {"productId": 70765, "lowPrice": 100.0, "midPrice": 119.99, "highPrice": 400.0, "marketPrice": 85.88, "directLowPrice": null, "subTypeName": "Normal"}, {"productId": 70782, "lowPrice": 7.26, "midPrice": 13.24, "highPrice": 20.57, "marketPrice": 13.16, "directLowPrice": 17.05, "subTypeName": "Foil"}, {"productId": 70783, "lowPrice": 4.0, "midPrice": 6.15, "highPrice": 11.84, "marketPrice": 5.69, "directLowPrice": null, "subTypeName": "Foil"}, {"productId": 70784, "lowPrice": 3.86, "midPrice": 5.41, "highPrice": 19.55, "marketPrice": 4.66, "directLowPrice": null, "subTypeName": "Foil"}, {"productId": 70785, "lowPrice": 1.25, "midPrice": 1.9, "highPrice": 7.4, "marketPrice": 1.6, "directLowPrice": null, "subTypeName": "Foil"}, {"productId": 70786, "lowPrice": 3.0, "midPrice": 4.4, "highPrice": 10.0, "marketPrice": 3.42, "directLowPrice": 4.39, "subTypeName": "Foil"}, {"productId": 70787, "lowPrice": 9.25, "midPrice": 11.47, "highPrice": 49.88, "marketPrice": 11.19, "directLowPrice": 19.37, "subTypeName": "Foil"}, {"productId": 70789, "lowPrice": 0.5, "midPrice": 1.04, "highPrice": 16.99, "marketPrice": 0.8, "directLowPrice": 1.07, "subTypeName": "Foil"}, {"productId": 70790, "lowPrice": 0.39, "midPrice": 0.99, "highPrice": 16.45, "marketPrice": 0.68, "directLowPrice": 0.6, "subTypeName": "Foil"}, {"productId": 70791, "lowPrice": 0.23, "midPrice": 0.59, "highPrice": 3.99, "marketPrice": 0.45, "directLowPrice": 0.49, "subTypeName": "Foil"}, {"productId": 70792, "lowPrice": 1.5, "midPrice": 2.49, "highPrice": 9.99, "marketPrice": 2.26, "directLowPrice": null, "subTypeName": "Foil"}, {"productId": 70793, "lowPrice": 49.0, "midPrice": 59.02, "highPrice": 200.0, "marketPrice": 59.02, "directLowPrice": 51.49, "subTypeName": "Foil"}, {"productId": 70794, "lowPrice": 1.8, "midPrice": 3.91, "highPrice": 88.0, "marketPrice": 3.69, "directLowPrice": 2.94, "subTypeName": "Foil"}]}
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user