Compare commits

...

5 Commits

Author SHA1 Message Date
dca11b0ede asdf 2025-05-30 17:34:40 -04:00
f2c2b69d63 asdf 2025-05-30 17:31:59 -04:00
5c85411c69 we are so back 2025-05-05 14:05:12 -04:00
11aa4cda16 pricing 2025-04-29 00:04:29 -04:00
c9bba8a26e PRICING 2025-04-29 00:00:47 -04:00
40 changed files with 3743 additions and 732 deletions

View File

@@ -28,13 +28,6 @@ target_metadata = Base.metadata
# ... etc. # ... etc.
def include_object(object, name, type_, reflected, compare_to):
# Skip materialized views during migrations
if type_ == "table" and name == "most_recent_tcgplayer_price":
return False
return True
def run_migrations_offline() -> None: def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode. """Run migrations in 'offline' mode.
@@ -53,7 +46,6 @@ def run_migrations_offline() -> None:
target_metadata=target_metadata, target_metadata=target_metadata,
literal_binds=True, literal_binds=True,
dialect_opts={"paramstyle": "named"}, dialect_opts={"paramstyle": "named"},
include_object=include_object,
) )
with context.begin_transaction(): with context.begin_transaction():
@@ -77,7 +69,6 @@ def run_migrations_online() -> None:
context.configure( context.configure(
connection=connection, connection=connection,
target_metadata=target_metadata, target_metadata=target_metadata,
include_object=include_object,
) )
with context.begin_transaction(): with context.begin_transaction():

View 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 ###

View 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 ###

View 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 ###

View File

@@ -1,8 +1,8 @@
"""why god """alembic is actually behaving so this message will be nice :)
Revision ID: d0792389ab20 Revision ID: cf61f006db46
Revises: Revises:
Create Date: 2025-04-24 22:36:02.509040 Create Date: 2025-04-25 14:34:28.206737
""" """
from typing import Sequence, Union from typing import Sequence, Union
@@ -12,7 +12,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = 'd0792389ab20' revision: str = 'cf61f006db46'
down_revision: Union[str, None] = None down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
@@ -63,6 +63,23 @@ def upgrade() -> None:
) )
op.create_index(op.f('ix_marketplaces_id'), 'marketplaces', ['id'], unique=False) 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_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', op.create_table('mtgjson_cards',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('mtgjson_uuid', sa.String(), nullable=True), sa.Column('mtgjson_uuid', sa.String(), nullable=True),
@@ -91,12 +108,14 @@ def upgrade() -> None:
sa.Column('scryfall_card_back_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_oracle_id', sa.String(), nullable=True),
sa.Column('scryfall_illustration_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_product_id', sa.Integer(), nullable=True),
sa.Column('tcgplayer_etched_product_id', sa.String(), nullable=True), sa.Column('tcgplayer_etched_product_id', sa.Integer(), nullable=True),
sa.Column('tnt_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('now()'), 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.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id') 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_id'), 'mtgjson_cards', ['id'], unique=False)
op.create_index(op.f('ix_mtgjson_cards_mtgjson_uuid'), 'mtgjson_cards', ['mtgjson_uuid'], unique=True) op.create_index(op.f('ix_mtgjson_cards_mtgjson_uuid'), 'mtgjson_cards', ['mtgjson_uuid'], unique=True)
@@ -109,6 +128,7 @@ def upgrade() -> None:
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id') 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_index(op.f('ix_sealed_expected_values_id'), 'sealed_expected_values', ['id'], unique=False)
op.create_table('tcgplayer_categories', op.create_table('tcgplayer_categories',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
@@ -238,6 +258,7 @@ def upgrade() -> None:
op.create_table('manabox_import_staging', op.create_table('manabox_import_staging',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('file_id', sa.Integer(), nullable=True), 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('tcgplayer_sku_id', sa.Integer(), nullable=True),
sa.Column('quantity', 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('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
@@ -325,10 +346,12 @@ def upgrade() -> None:
sa.Column('printing', 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('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), 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.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.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=True) 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_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_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_index(op.f('ix_mtgjson_skus_tcgplayer_sku_id'), 'mtgjson_skus', ['tcgplayer_sku_id'], unique=True)
@@ -369,6 +392,24 @@ def upgrade() -> None:
) )
op.create_index(op.f('ix_tcgplayer_inventory_id'), 'tcgplayer_inventory', ['id'], unique=False) 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_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', op.create_table('inventory_items',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('physical_item_id', sa.Integer(), nullable=True), sa.Column('physical_item_id', sa.Integer(), nullable=True),
@@ -383,26 +424,18 @@ def upgrade() -> None:
sa.UniqueConstraint('physical_item_id') sa.UniqueConstraint('physical_item_id')
) )
op.create_index(op.f('ix_inventory_items_id'), 'inventory_items', ['id'], unique=False) op.create_index(op.f('ix_inventory_items_id'), 'inventory_items', ['id'], unique=False)
op.create_table('sealed_cases', op.create_table('open_events',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('expected_value', sa.Float(), nullable=True), sa.Column('source_item_id', sa.Integer(), nullable=True),
sa.Column('num_boxes', sa.Integer(), nullable=True), sa.Column('open_date', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['id'], ['physical_items.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('transaction_items',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('transaction_id', sa.Integer(), nullable=True),
sa.Column('physical_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('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('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['physical_item_id'], ['physical_items.id'], ), sa.ForeignKeyConstraint(['source_item_id'], ['physical_items.id'], ),
sa.ForeignKeyConstraint(['transaction_id'], ['transactions.id'], ), sa.PrimaryKeyConstraint('id'),
sa.PrimaryKeyConstraint('id') sa.UniqueConstraint('source_item_id', name='uq_openevent_one_per_source')
) )
op.create_index(op.f('ix_transaction_items_id'), 'transaction_items', ['id'], unique=False) op.create_index(op.f('ix_open_events_id'), 'open_events', ['id'], unique=False)
op.create_table('marketplace_listings', op.create_table('marketplace_listings',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('inventory_item_id', sa.Integer(), nullable=False), sa.Column('inventory_item_id', sa.Integer(), nullable=False),
@@ -418,63 +451,44 @@ def upgrade() -> None:
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
op.create_index(op.f('ix_marketplace_listings_id'), 'marketplace_listings', ['id'], unique=False) op.create_index(op.f('ix_marketplace_listings_id'), 'marketplace_listings', ['id'], unique=False)
op.create_table('sealed_boxes', op.create_table('open_event_resulting_items',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('event_id', sa.Integer(), nullable=False),
sa.Column('case_id', sa.Integer(), nullable=True), sa.Column('item_id', sa.Integer(), nullable=False),
sa.Column('expected_value', sa.Float(), nullable=True), sa.ForeignKeyConstraint(['event_id'], ['open_events.id'], ),
sa.ForeignKeyConstraint(['case_id'], ['sealed_cases.id'], ), sa.ForeignKeyConstraint(['item_id'], ['physical_items.id'], ),
sa.ForeignKeyConstraint(['id'], ['physical_items.id'], ), sa.PrimaryKeyConstraint('event_id', 'item_id')
sa.PrimaryKeyConstraint('id')
) )
op.create_table('open_events', op.create_table('transaction_items',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('sealed_case_id', sa.Integer(), nullable=True), sa.Column('transaction_id', sa.Integer(), nullable=True),
sa.Column('sealed_box_id', sa.Integer(), nullable=True), sa.Column('inventory_item_id', sa.Integer(), nullable=True),
sa.Column('open_date', sa.DateTime(timezone=True), 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('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('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['sealed_box_id'], ['sealed_boxes.id'], ), sa.ForeignKeyConstraint(['inventory_item_id'], ['inventory_items.id'], ),
sa.ForeignKeyConstraint(['sealed_case_id'], ['sealed_cases.id'], ), sa.ForeignKeyConstraint(['transaction_id'], ['transactions.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_open_events_id'), 'open_events', ['id'], unique=False)
op.create_table('open_boxes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('open_event_id', sa.Integer(), nullable=True),
sa.Column('sealed_box_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['id'], ['physical_items.id'], ),
sa.ForeignKeyConstraint(['open_event_id'], ['open_events.id'], ),
sa.ForeignKeyConstraint(['sealed_box_id'], ['sealed_boxes.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('open_cards',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('open_event_id', sa.Integer(), nullable=True),
sa.Column('box_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['box_id'], ['open_boxes.id'], ),
sa.ForeignKeyConstraint(['id'], ['physical_items.id'], ),
sa.ForeignKeyConstraint(['open_event_id'], ['open_events.id'], ),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
op.create_index(op.f('ix_transaction_items_id'), 'transaction_items', ['id'], unique=False)
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
"""Downgrade schema.""" """Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_table('open_cards')
op.drop_table('open_boxes')
op.drop_index(op.f('ix_open_events_id'), table_name='open_events')
op.drop_table('open_events')
op.drop_table('sealed_boxes')
op.drop_index(op.f('ix_marketplace_listings_id'), table_name='marketplace_listings')
op.drop_table('marketplace_listings')
op.drop_index(op.f('ix_transaction_items_id'), table_name='transaction_items') op.drop_index(op.f('ix_transaction_items_id'), table_name='transaction_items')
op.drop_table('transaction_items') op.drop_table('transaction_items')
op.drop_table('sealed_cases') 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_index(op.f('ix_inventory_items_id'), table_name='inventory_items')
op.drop_table('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_tcgplayer_sku_id'), table_name='tcgplayer_inventory')
op.drop_index(op.f('ix_tcgplayer_inventory_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('tcgplayer_inventory')
@@ -517,10 +531,15 @@ def downgrade() -> None:
op.drop_index(op.f('ix_tcgplayer_categories_category_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_table('tcgplayer_categories')
op.drop_index(op.f('ix_sealed_expected_values_id'), table_name='sealed_expected_values') 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_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_mtgjson_uuid'), table_name='mtgjson_cards')
op.drop_index(op.f('ix_mtgjson_cards_id'), table_name='mtgjson_cards') op.drop_index(op.f('ix_mtgjson_cards_id'), table_name='mtgjson_cards')
op.drop_table('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_name'), table_name='marketplaces')
op.drop_index(op.f('ix_marketplaces_id'), table_name='marketplaces') op.drop_index(op.f('ix_marketplaces_id'), table_name='marketplaces')
op.drop_table('marketplaces') op.drop_table('marketplaces')

View File

@@ -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 ###

View File

@@ -8,10 +8,10 @@ class InventoryItemContext:
def __init__(self, item: InventoryItem, db: Session): def __init__(self, item: InventoryItem, db: Session):
self.item = item self.item = item
self.physical_item = item.physical_item self.physical_item = item.physical_item
self.marketplace_listings = item.marketplace_listings self.marketplace_listing = item.marketplace_listing
self.parent = item.parent self.parent = item.parent
self.children = item.children self.children = item.children
self.product = item.product self.product = item.products
self.db = db self.db = db
@property @property
@@ -44,9 +44,9 @@ class InventoryItemContext:
@property @property
def listed_price(self) -> float: def listed_price(self) -> float:
if not self.marketplace_listings: if not self.marketplace_listing:
return 0.0 return 0.0
return self.marketplace_listings[0].listed_price if self.marketplace_listings else 0.0 return self.marketplace_listing[0].listed_price if self.marketplace_listing else 0.0
def top_level_parent(self) -> "InventoryItemContext": def top_level_parent(self) -> "InventoryItemContext":
if self.parent: if self.parent:
@@ -71,9 +71,9 @@ class InventoryItemContext:
raise ValueError("Cannot find transaction unit price for this item") raise ValueError("Cannot find transaction unit price for this item")
def age_on_marketplace(self) -> int: def age_on_marketplace(self) -> int:
if not self.marketplace_listings: if not self.marketplace_listing:
return 0 return 0
return (datetime.now() - self.marketplace_listings[0].listing_date).days return (datetime.now() - self.marketplace_listing[0].listing_date).days
class InventoryItemContextFactory: class InventoryItemContextFactory:

View 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
1 Name Set code Set name Collector number Foil Rarity Quantity ManaBox ID Scryfall ID Purchase price Misprint Altered Condition Language Purchase price currency
2 Undergrowth Leopard TDM Tarkir: Dragonstorm 165 foil common 1 104307 67ab8f9a-b17c-452f-b4ef-a3f91909e3de 0.08 false false near_mint en USD
3 Gurmag Nightwatch TDM Tarkir: Dragonstorm 190 foil common 1 104369 de731430-6bbf-4782-953e-b69c46353959 0.03 false false near_mint en USD
4 Mystic Monastery TDM Tarkir: Dragonstorm 262 foil uncommon 1 104945 c7b8a01c-c400-47c7-8270-78902efe850e 0.21 false false near_mint en USD
5 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
6 Reigning Victor TDM Tarkir: Dragonstorm 216 foil common 1 104334 a394112a-032b-4047-887a-6522cf7b83d5 0.02 false false near_mint en USD
7 Dragonbroods' Relic TDM Tarkir: Dragonstorm 140 foil uncommon 1 104569 3d634087-77ba-4543-aa7a-8a3774d69cd7 0.13 false false near_mint en USD
8 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
9 Sibsig Appraiser TDM Tarkir: Dragonstorm 56 foil common 1 105135 670c5b96-bac6-449b-a2bd-cb43750d3911 0.05 false false near_mint en USD
10 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
11 Snowmelt Stag TDM Tarkir: Dragonstorm 57 foil common 1 104869 a6b3b131-704a-4586-84f8-db465cd4a277 0.04 false false near_mint en USD
12 Tranquil Cove TDM Tarkir: Dragonstorm 270 foil common 1 104249 1c4efa6c-4f29-41cd-a728-bf0e479ace05 0.07 false false near_mint en USD
13 Rally the Monastery TDM Tarkir: Dragonstorm 19 foil uncommon 1 104136 b56e0037-8143-4c13-83e1-0c3f44e685ea 0.22 false false near_mint en USD
14 Dragon's Prey TDM Tarkir: Dragonstorm 79 foil common 1 104754 7a6004ff-4180-4332-8b51-960f8c7521d9 0.03 false false near_mint en USD
15 Ambling Stormshell TDM Tarkir: Dragonstorm 37 foil rare 1 104942 c74d4a57-0f66-4965-9ed7-f88a08aa1d15 0.45 false false near_mint en USD
16 Mountain TDM Tarkir: Dragonstorm 275 foil common 1 104397 fe0865ba-47c0-40bc-b0c6-e1ea5ae08a98 1.83 false false near_mint en USD
17 Mardu Devotee TDM Tarkir: Dragonstorm 16 foil common 1 104366 da45e9b0-a4f6-413b-9e62-666c511eb5b0 0.09 false false near_mint en USD
18 Swiftwater Cliffs TDM Tarkir: Dragonstorm 268 foil common 1 104361 ca53fb19-b8ca-485b-af1a-5117ae54bfe3 0.11 false false near_mint en USD
19 Adorned Crocodile TDM Tarkir: Dragonstorm 69 foil common 1 105159 bb13a34b-6ac8-47cb-9e91-47106a585fc1 0.05 false false near_mint en USD
20 Dusyut Earthcarver TDM Tarkir: Dragonstorm 141 foil common 1 104352 b98ecc96-f557-479a-8685-2b5487d5b407 0.02 false false near_mint en USD
21 Knockout Maneuver TDM Tarkir: Dragonstorm 147 foil uncommon 1 105149 9d218831-2a41-46a3-8e9d-93462cae5cab 0.07 false false near_mint en USD
22 Roiling Dragonstorm TDM Tarkir: Dragonstorm 55 foil uncommon 1 104280 455f4c96-684b-4b14-bd21-6799da2e1fa7 0.22 false false near_mint en USD
23 Dragonclaw Strike TDM Tarkir: Dragonstorm 180 foil uncommon 1 105161 bc7692ef-7091-4365-85a8-1edbd374f279 0.12 false false near_mint en USD
24 Seize Opportunity TDM Tarkir: Dragonstorm 119 foil common 1 104391 f7818d28-b9a5-4341-9adc-666070b8878d 0.03 false false near_mint en USD
25 Shock Brigade TDM Tarkir: Dragonstorm 120 foil common 1 104700 66940466-8e9d-4a85-bfb0-e92189b7a121 0.11 false false near_mint en USD
26 Hardened Tactician TDM Tarkir: Dragonstorm 191 foil uncommon 1 104780 86b225cb-5c45-4da1-a64e-b04091e483e8 0.43 false false near_mint en USD
27 Stormplain Detainment TDM Tarkir: Dragonstorm 28 foil common 1 104135 39f3aab5-7b54-4b55-8114-c6f9f79c255d 0.04 false false near_mint en USD
28 Formation Breaker TDM Tarkir: Dragonstorm 143 foil uncommon 1 105136 67ab8e8f-3ef6-4339-8c66-68c5aca4867a 0.08 false false near_mint en USD
29 Duty Beyond Death TDM Tarkir: Dragonstorm 10 foil uncommon 1 104265 2e92640d-768b-4357-905f-bea017d351cc 1.11 false false near_mint en USD
30 Piercing Exhale TDM Tarkir: Dragonstorm 151 foil common 1 104891 b2a0deb9-5bc3-42d5-9e1e-5f463d176aef 0.04 false false near_mint en USD
31 Trade Route Envoy TDM Tarkir: Dragonstorm 163 foil common 1 105174 f0c89d95-d697-4cfa-9dfa-52d7adb96176 0.05 false false near_mint en USD
32 Thornwood Falls TDM Tarkir: Dragonstorm 269 foil common 1 104376 ebb502c2-5fd0-46a9-b77d-010f4a942056 0.07 false false near_mint en USD
33 Kin-Tree Nurturer TDM Tarkir: Dragonstorm 83 foil common 1 105124 2177ef64-28bf-4acf-b1f1-c1408f03c411 0.03 false false near_mint en USD
34 Rebellious Strike TDM Tarkir: Dragonstorm 20 foil common 1 104949 c9bafe19-3bd6-4da0-b3e5-e0b89262504c 0.06 false false near_mint en USD
35 Scoured Barrens TDM Tarkir: Dragonstorm 267 foil common 1 104346 b4b47b80-69ed-44b0-afa0-ca90206dc16d 0.06 false false near_mint en USD
36 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
37 Dismal Backwater TDM Tarkir: Dragonstorm 254 foil common 1 104238 082b52c9-c46e-44d3-b723-546ba528e07b 0.07 false false near_mint en USD
38 Rediscover the Way TDM Tarkir: Dragonstorm 215 normal rare 1 104313 79d6decf-afd5-4e96-b87e-fd7ab7e3c068 0.19 false false near_mint en USD
39 Auroral Procession TDM Tarkir: Dragonstorm 169 normal uncommon 1 104701 672f94ad-65d6-4c7d-925d-165ef264626f 0.22 false false near_mint en USD
40 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
41 Stadium Headliner TDM Tarkir: Dragonstorm 122 normal rare 1 104552 37d4ab2a-a06a-4768-b5e1-e1def957d7f4 0.44 false false near_mint en USD
42 Nature's Rhythm TDM Tarkir: Dragonstorm 150 normal rare 1 104460 1397d904-c51d-451e-8505-7f3118acc1f6 3.08 false false near_mint en USD
43 Karakyk Guardian TDM Tarkir: Dragonstorm 198 normal uncommon 1 104859 a4c77b08-c3f6-4458-8636-f226f9843b6d 0.08 false false near_mint en USD
44 Anafenza, Unyielding Lineage TDM Tarkir: Dragonstorm 2 normal rare 1 104258 29957f49-9a6b-42f6-b2fb-b48f653ab725 0.22 false false near_mint en USD
45 Narset, Jeskai Waymaster TDM Tarkir: Dragonstorm 209 normal rare 1 103995 6b77cbc1-dbc8-44d9-aa29-15cbb19afecd 0.22 false false near_mint en USD
46 Stillness in Motion TDM Tarkir: Dragonstorm 59 normal rare 1 104864 a6289251-17e4-4987-96b9-2fb1a8f90e2a 0.17 false false near_mint en USD
47 Thunder of Unity TDM Tarkir: Dragonstorm 231 normal rare 1 104671 5c953b36-f5e4-4258-91cb-f07e799321f7 0.14 false false near_mint en USD
48 The Sibsig Ceremony TDM Tarkir: Dragonstorm 340 normal rare 1 104719 6daa156c-478f-47dd-9284-b95e82ccfd68 0.67 false false near_mint en USD
49 Tersa Lightshatter TDM Tarkir: Dragonstorm 127 normal rare 1 104825 99e96b34-b1c4-4647-a38e-2cf1aedaaace 2.31 false false near_mint en USD
50 Forest TDM Tarkir: Dragonstorm 286 normal common 1 104324 8e3e83d2-96ba-4d5c-a1ed-6c08a90b339c 0.07 false false near_mint en USD
51 Windcrag Siege TDM Tarkir: Dragonstorm 235 normal rare 1 104534 31a8329b-23a1-4c49-a579-a5da8d01435a 1.68 false false near_mint en USD
52 Mountain TDM Tarkir: Dragonstorm 284 normal common 1 104274 3df7c206-97b6-49d7-ba01-7a35fd8c61d9 0.05 false false near_mint en USD
53 Inevitable Defeat TDM Tarkir: Dragonstorm 194 normal rare 1 103997 9d677980-b608-407e-9f17-790a81263f15 0.28 false false near_mint en USD
54 Dalkovan Encampment TDM Tarkir: Dragonstorm 394 normal rare 1 104670 5af006f6-135e-4ea0-8ce4-7824934e87da 0.72 false false near_mint en USD
55 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
56 Sarkhan, Dragon Ascendant TDM Tarkir: Dragonstorm 118 normal rare 1 104003 c2200646-7b7c-489d-bbae-16b03e1d7fb2 0.32 false false near_mint en USD
57 Stormscale Scion TDM Tarkir: Dragonstorm 123 normal mythic 1 103987 0ac43386-bd32-425c-8776-cec00b064cbc 6.78 false false near_mint en USD
58 Dragon Sniper TDM Tarkir: Dragonstorm 139 normal uncommon 1 105120 074b1e00-45bb-4436-8f5e-058512b2d08a 0.25 false false near_mint en USD
59 Island TDM Tarkir: Dragonstorm 273 normal common 1 104276 4208e66c-8c98-4c48-ab07-8523c0b26ca4 1.02 false false near_mint en USD
60 Yathan Roadwatcher TDM Tarkir: Dragonstorm 236 normal rare 1 104800 8e77339b-dd82-481c-9ee2-4156ca69ad35 0.14 false false near_mint en USD
61 Nomad Outpost TDM Tarkir: Dragonstorm 263 normal uncommon 1 104868 a68fbeaa-941f-4d53-becd-f93ed22b9a54 0.12 false false near_mint en USD
62 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
63 Kheru Goldkeeper TDM Tarkir: Dragonstorm 199 normal uncommon 1 104798 8d11183a-57f5-4ddb-8a6e-15fff704b114 0.18 false false near_mint en USD
64 All-Out Assault TDM Tarkir: Dragonstorm 167 normal mythic 1 104348 b74876d8-f6a6-4b47-b960-b01a331bab01 4.11 false false near_mint en USD
65 Winternight Stories TDM Tarkir: Dragonstorm 67 normal rare 1 104693 64d9367c-f50c-4568-aa63-6760c44ecaeb 0.44 false false near_mint en USD
66 New Way Forward TDM Tarkir: Dragonstorm 211 normal rare 1 104996 d9d48f9e-79f0-478c-9db0-ff7ac4a8f401 0.17 false false near_mint en USD
67 Strategic Betrayal TDM Tarkir: Dragonstorm 94 normal uncommon 1 105145 95617742-548d-464a-bb89-a858ffa9018f 0.18 false false near_mint en USD
68 Opulent Palace TDM Tarkir: Dragonstorm 264 normal uncommon 1 104491 21cb3b3b-0738-4c2e-a3fc-927fd6b9d3fb 0.14 false false near_mint en USD
69 United Battlefront TDM Tarkir: Dragonstorm 32 normal rare 1 104370 dff398be-4ba4-4976-9acc-be99d2e07a61 0.51 false false near_mint en USD
70 Temur Battlecrier TDM Tarkir: Dragonstorm 228 normal rare 1 104309 72184791-0767-4108-920c-763e92dae2d4 0.68 false false near_mint en USD
71 Kotis, the Fangkeeper TDM Tarkir: Dragonstorm 362 normal rare 1 104388 f70098f2-e5a8-4056-b5b3-1229fc290c51 0.48 false false near_mint en USD
72 Forest TDM Tarkir: Dragonstorm 285 normal common 1 104317 8100bceb-ffba-487a-bb45-4fe2a156a8dc 0.06 false false near_mint en USD
73 Dragonfire Blade TDM Tarkir: Dragonstorm 240 normal rare 1 104427 031afea3-fbfb-4663-a8cc-9b7eb7b16020 0.64 false false near_mint en USD
74 Great Arashin City TDM Tarkir: Dragonstorm 257 normal rare 1 105033 ecba23b6-9f3a-431e-bc22-f1fb04d27b68 0.33 false false near_mint en USD
75 Smile at Death TDM Tarkir: Dragonstorm 24 normal mythic 1 104000 ae2da18f-0d7d-446c-b463-8bf170ed95da 3.51 false false near_mint en USD
76 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
77 Eshki Dragonclaw TDM Tarkir: Dragonstorm 182 normal rare 1 104445 0d369c44-78ee-4f3c-bf2b-cddba7fe26d4 0.19 false false near_mint en USD
78 Skirmish Rhino TDM Tarkir: Dragonstorm 224 normal uncommon 1 103992 4a2e9ba1-c254-41e3-9845-4e81f9fec38d 0.15 false false near_mint en USD
79 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
80 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
81 Hardened Tactician TDM Tarkir: Dragonstorm 191 normal uncommon 1 104780 86b225cb-5c45-4da1-a64e-b04091e483e8 1.0 false false near_mint en USD
82 Sandsteppe Citadel TDM Tarkir: Dragonstorm 266 normal uncommon 1 104603 47f47e7f-39ba-4807-8e32-7262a61dfbba 0.13 false false near_mint en USD
83 Kotis, the Fangkeeper TDM Tarkir: Dragonstorm 202 normal rare 1 104364 d3736f17-f80b-4b2c-b919-2c963bc14682 0.28 false false near_mint en USD
84 Magmatic Hellkite TDM Tarkir: Dragonstorm 111 normal rare 1 104895 b3b3aec8-d931-4c7f-86b5-1e7dfb717b59 0.56 false false near_mint en USD
85 Dalkovan Encampment TDM Tarkir: Dragonstorm 253 normal rare 1 104822 98ad5f0c-8775-4e89-8e92-84a6ade93e35 0.38 false false near_mint en USD
86 Ambling Stormshell TDM Tarkir: Dragonstorm 37 normal rare 1 104942 c74d4a57-0f66-4965-9ed7-f88a08aa1d15 0.18 false false near_mint en USD
87 Hollowmurk Siege TDM Tarkir: Dragonstorm 192 normal rare 1 104668 5ac0e136-8877-4bfc-a831-2bf7b7b5ad1e 0.53 false false near_mint en USD
88 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
89 Mystic Monastery TDM Tarkir: Dragonstorm 262 normal uncommon 1 104945 c7b8a01c-c400-47c7-8270-78902efe850e 0.11 false false near_mint en USD
90 Songcrafter Mage TDM Tarkir: Dragonstorm 225 normal rare 1 104813 9523bc07-49e5-409c-ae6b-b28e305eef36 0.35 false false near_mint en USD
91 Misty Rainforest SPG Special Guests 111 normal mythic 1 104321 894105c4-d3ce-4d38-855b-24aa47b112c1 32.31 false false near_mint en USD
92 Tempest Hawk TDM Tarkir: Dragonstorm 31 normal common 3 104587 422f9453-ab12-4e3c-8c51-be87391395a1 0.92 false false near_mint en USD
93 Duty Beyond Death TDM Tarkir: Dragonstorm 10 normal uncommon 2 104265 2e92640d-768b-4357-905f-bea017d351cc 0.33 false false near_mint en USD
94 Heritage Reclamation TDM Tarkir: Dragonstorm 145 normal common 2 104636 4f8fee37-a050-4329-8b10-46d150e7a95e 0.2 false false near_mint en USD

View 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
1 Name Set code Set name Collector number Foil Rarity Quantity ManaBox ID Scryfall ID Purchase price Misprint Altered Condition Language Purchase price currency
2 Sunpearl Kirin TDM Tarkir: Dragonstorm 29 foil uncommon 1 104470 18292b9c-0f42-4ce2-8b85-35d06cf45a63 1.24 false false near_mint en USD
3 Humbling Elder TDM Tarkir: Dragonstorm 48 foil common 1 104562 3a84c3f8-0030-4653-880e-b2d19272f5fa 0.06 false false near_mint en USD
4 Fortress Kin-Guard TDM Tarkir: Dragonstorm 12 foil common 1 104900 b647a018-1d70-43a1-a265-928bcd863689 0.04 false false near_mint en USD
5 Wild Ride TDM Tarkir: Dragonstorm 132 foil common 1 105154 abc8c6f5-6135-428e-8476-1751f82623f9 0.14 false false near_mint en USD
6 Jade-Cast Sentinel TDM Tarkir: Dragonstorm 243 foil common 1 104293 516ce5fa-bd00-429b-ba22-b38c7dd9306c 0.07 false false near_mint en USD
7 Sibsig Appraiser TDM Tarkir: Dragonstorm 56 foil common 1 105135 670c5b96-bac6-449b-a2bd-cb43750d3911 0.04 false false near_mint en USD
8 Channeled Dragonfire TDM Tarkir: Dragonstorm 102 foil uncommon 1 104499 24204881-690c-4043-8771-20cb93385072 0.07 false false near_mint en USD
9 Meticulous Artisan TDM Tarkir: Dragonstorm 112 foil common 1 104912 baf4c9dd-0546-41ac-a7ba-0bc312fef31e 0.03 false false near_mint en USD
10 Kishla Trawlers TDM Tarkir: Dragonstorm 50 foil uncommon 1 104472 190fbc55-e8e9-4077-9532-1de7406baabf 0.08 false false near_mint en USD
11 Rite of Renewal TDM Tarkir: Dragonstorm 153 foil uncommon 1 104390 f737698a-d934-4851-b238-828959ef4835 0.07 false false near_mint en USD
12 Twin Bolt TDM Tarkir: Dragonstorm 128 foil common 1 105137 688d8e93-d071-4089-9ef9-565ac4ae9ae0 0.04 false false near_mint en USD
13 Heritage Reclamation TDM Tarkir: Dragonstorm 145 foil common 1 104636 4f8fee37-a050-4329-8b10-46d150e7a95e 0.33 false false near_mint en USD
14 Sarkhan's Resolve TDM Tarkir: Dragonstorm 158 foil common 1 104952 cae56fef-b661-4bc5-b9a1-3871ae06e491 0.04 false false near_mint en USD
15 Delta Bloodflies TDM Tarkir: Dragonstorm 77 foil common 1 104457 119bb72d-aed9-47dc-9285-7bc836cc3776 0.05 false false near_mint en USD
16 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
17 Kheru Goldkeeper TDM Tarkir: Dragonstorm 199 foil uncommon 1 104798 8d11183a-57f5-4ddb-8a6e-15fff704b114 0.37 false false near_mint en USD
18 Rainveil Rejuvenator TDM Tarkir: Dragonstorm 152 foil uncommon 1 105148 9bc5c316-6a41-48ba-864b-da3030dd3e0e 0.12 false false near_mint en USD
19 Rebellious Strike TDM Tarkir: Dragonstorm 20 foil common 1 104949 c9bafe19-3bd6-4da0-b3e5-e0b89262504c 0.06 false false near_mint en USD
20 Jeskai Brushmaster TDM Tarkir: Dragonstorm 195 foil uncommon 1 104526 2eb06c36-cf7e-47a9-819e-adfc54284153 0.09 false false near_mint en USD
21 Highspire Bell-Ringer TDM Tarkir: Dragonstorm 47 foil common 1 105020 e75dccf7-2894-4c4a-b516-3eee73acddd3 0.06 false false near_mint en USD
22 Sagu Pummeler TDM Tarkir: Dragonstorm 156 foil common 1 105169 def9cb5b-4062-481e-b682-3a30443c2e56 0.03 false false near_mint en USD
23 Roamer's Routine TDM Tarkir: Dragonstorm 154 foil common 1 104396 fb8c2d5c-ba0c-4d50-8898-5c6574b1e974 0.13 false false near_mint en USD
24 Monastery Messenger TDM Tarkir: Dragonstorm 208 foil common 1 104443 0c9eeced-6464-41f0-bbea-05b3af4cc005 0.04 false false near_mint en USD
25 Equilibrium Adept TDM Tarkir: Dragonstorm 106 foil uncommon 1 104335 a4ba6d74-c6be-4a5e-8859-b791bb6b8f51 0.07 false false near_mint en USD
26 Dispelling Exhale TDM Tarkir: Dragonstorm 41 foil common 1 104477 1c9af3f1-711e-42ae-803a-1100eba3fb13 0.21 false false near_mint en USD
27 Nightblade Brigade TDM Tarkir: Dragonstorm 85 foil common 1 105134 648debd9-d4cf-4788-8882-f1601a3d87f5 0.08 false false near_mint en USD
28 Tranquil Cove TDM Tarkir: Dragonstorm 270 foil common 1 104249 1c4efa6c-4f29-41cd-a728-bf0e479ace05 0.06 false false near_mint en USD
29 Rugged Highlands TDM Tarkir: Dragonstorm 265 foil common 1 104267 31261eca-28ad-407c-84ef-0c124d0d7451 0.07 false false near_mint en USD
30 Swiftwater Cliffs TDM Tarkir: Dragonstorm 268 foil common 1 104361 ca53fb19-b8ca-485b-af1a-5117ae54bfe3 0.11 false false near_mint en USD
31 Dismal Backwater TDM Tarkir: Dragonstorm 254 foil common 1 104238 082b52c9-c46e-44d3-b723-546ba528e07b 0.06 false false near_mint en USD
32 Wind-Scarred Crag TDM Tarkir: Dragonstorm 271 foil common 1 104286 4912e4d0-b16a-4aa6-a583-3430d26bd591 0.05 false false near_mint en USD
33 Jungle Hollow TDM Tarkir: Dragonstorm 258 foil common 1 104375 ea13440b-3f7b-4182-9541-27c1fa3121e5 0.07 false false near_mint en USD
34 Plains TDM Tarkir: Dragonstorm 272 foil common 1 104240 0d0f1dd6-9564-4adc-af7d-f83252e8581a 0.39 false false near_mint en USD
35 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
36 Barrensteppe Siege TDM Tarkir: Dragonstorm 384 foil rare 1 104002 c09d4015-f101-4529-a603-c66192dcfd92 1.68 false false near_mint en USD
37 United Battlefront TDM Tarkir: Dragonstorm 32 foil rare 1 104370 dff398be-4ba4-4976-9acc-be99d2e07a61 0.8 false false near_mint en USD
38 Cori-Steel Cutter TDM Tarkir: Dragonstorm 103 foil rare 1 104608 490eb213-9ae2-4b45-abec-6f1dfc83792a 15.2 false false near_mint en USD
39 Plains TDM Tarkir: Dragonstorm 272 normal common 1 104240 0d0f1dd6-9564-4adc-af7d-f83252e8581a 0.52 false false near_mint en USD
40 Skirmish Rhino TDM Tarkir: Dragonstorm 224 normal uncommon 1 103992 4a2e9ba1-c254-41e3-9845-4e81f9fec38d 0.18 false false near_mint en USD
41 Kheru Goldkeeper TDM Tarkir: Dragonstorm 199 normal uncommon 1 104798 8d11183a-57f5-4ddb-8a6e-15fff704b114 0.17 false false near_mint en USD
42 Hardened Tactician TDM Tarkir: Dragonstorm 191 normal uncommon 1 104780 86b225cb-5c45-4da1-a64e-b04091e483e8 0.19 false false near_mint en USD
43 Auroral Procession TDM Tarkir: Dragonstorm 169 normal uncommon 1 104701 672f94ad-65d6-4c7d-925d-165ef264626f 0.2 false false near_mint en USD
44 Barrensteppe Siege TDM Tarkir: Dragonstorm 171 normal rare 1 103989 2556a35b-2229-42c7-8cb3-c8c668403dd2 0.46 false false near_mint en USD
45 Heritage Reclamation TDM Tarkir: Dragonstorm 145 normal common 1 104636 4f8fee37-a050-4329-8b10-46d150e7a95e 0.19 false false near_mint en USD
46 Windcrag Siege TDM Tarkir: Dragonstorm 235 normal rare 1 104534 31a8329b-23a1-4c49-a579-a5da8d01435a 1.97 false false near_mint en USD
47 Ambling Stormshell TDM Tarkir: Dragonstorm 37 normal rare 1 104942 c74d4a57-0f66-4965-9ed7-f88a08aa1d15 0.18 false false near_mint en USD
48 Narset, Jeskai Waymaster TDM Tarkir: Dragonstorm 209 normal rare 1 103995 6b77cbc1-dbc8-44d9-aa29-15cbb19afecd 0.21 false false near_mint en USD
49 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
50 Dragonback Assault TDM Tarkir: Dragonstorm 179 normal mythic 1 104985 d54cc838-d79d-433a-99fb-d6e4d1c1431d 3.49 false false near_mint en USD
51 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
52 Flamehold Grappler TDM Tarkir: Dragonstorm 185 normal rare 1 104958 cc8443a6-282f-4218-9dc8-144b5570d891 0.26 false false near_mint en USD
53 New Way Forward TDM Tarkir: Dragonstorm 211 normal rare 1 104996 d9d48f9e-79f0-478c-9db0-ff7ac4a8f401 0.2 false false near_mint en USD
54 Eshki Dragonclaw TDM Tarkir: Dragonstorm 182 normal rare 1 104445 0d369c44-78ee-4f3c-bf2b-cddba7fe26d4 0.22 false false near_mint en USD
55 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
56 United Battlefront TDM Tarkir: Dragonstorm 32 normal rare 1 104370 dff398be-4ba4-4976-9acc-be99d2e07a61 0.58 false false near_mint en USD
57 Dalkovan Encampment TDM Tarkir: Dragonstorm 253 normal rare 1 104822 98ad5f0c-8775-4e89-8e92-84a6ade93e35 0.3 false false near_mint en USD
58 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
59 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
60 Tersa Lightshatter TDM Tarkir: Dragonstorm 127 normal rare 1 104825 99e96b34-b1c4-4647-a38e-2cf1aedaaace 1.92 false false near_mint en USD
61 Voice of Victory TDM Tarkir: Dragonstorm 33 normal rare 1 104377 ec3de5f4-bb55-4ab9-995f-f3e0dc22c1bb 10.45 false false near_mint en USD
62 Yathan Roadwatcher TDM Tarkir: Dragonstorm 236 normal rare 1 104800 8e77339b-dd82-481c-9ee2-4156ca69ad35 0.15 false false near_mint en USD
63 Great Arashin City TDM Tarkir: Dragonstorm 257 normal rare 1 105033 ecba23b6-9f3a-431e-bc22-f1fb04d27b68 0.31 false false near_mint en USD
64 Taigam, Master Opportunist TDM Tarkir: Dragonstorm 60 normal mythic 1 104320 8693d631-05f6-414d-9e49-6385746e8960 1.69 false false near_mint en USD
65 Temur Battlecrier TDM Tarkir: Dragonstorm 228 normal rare 1 104309 72184791-0767-4108-920c-763e92dae2d4 0.74 false false near_mint en USD
66 Fangkeeper's Familiar TDM Tarkir: Dragonstorm 183 normal rare 1 104696 655fa2e1-3e1c-424c-b17a-daa7b8fface4 0.27 false false near_mint en USD
67 Nature's Rhythm TDM Tarkir: Dragonstorm 150 normal rare 1 104460 1397d904-c51d-451e-8505-7f3118acc1f6 3.21 false false near_mint en USD
68 Stillness in Motion TDM Tarkir: Dragonstorm 59 normal rare 1 104864 a6289251-17e4-4987-96b9-2fb1a8f90e2a 0.18 false false near_mint en USD
69 Stadium Headliner TDM Tarkir: Dragonstorm 122 normal rare 1 104552 37d4ab2a-a06a-4768-b5e1-e1def957d7f4 0.46 false false near_mint en USD
70 Sinkhole Surveyor TDM Tarkir: Dragonstorm 93 normal rare 1 104551 37cb5599-7d2c-48e9-978b-902a01a74bde 0.23 false false near_mint en USD
71 Naga Fleshcrafter TDM Tarkir: Dragonstorm 52 normal rare 1 104675 5df17423-9fdd-4432-8660-1d267c685595 0.31 false false near_mint en USD
72 Herd Heirloom TDM Tarkir: Dragonstorm 144 normal rare 1 104873 a88c7713-b3a9-4685-b1d3-623d35b62365 4.48 false false near_mint en USD
73 Severance Priest TDM Tarkir: Dragonstorm 222 normal rare 1 104917 bc779a1b-128c-4c74-bebd-bdb687867f68 0.21 false false near_mint en USD
74 Dracogenesis TDM Tarkir: Dragonstorm 105 normal mythic 1 104241 0d5674f9-22b2-45f9-902d-4fd245485c60 15.05 false false near_mint en USD
75 Kishla Village TDM Tarkir: Dragonstorm 259 normal rare 1 104840 9f0ff90d-7312-44df-afc5-29c768fa7758 0.3 false false near_mint en USD
76 Sarkhan, Dragon Ascendant TDM Tarkir: Dragonstorm 302 normal rare 1 103994 57c03255-e3dc-44c2-982b-7efa188280df 0.49 false false near_mint en USD
77 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
78 Kheru Goldkeeper TDM Tarkir: Dragonstorm 313 normal uncommon 1 105150 9d85ba44-8f29-4c49-b77f-8a6692d23c8c 0.45 false false near_mint en USD
79 Boulderborn Dragon TDM Tarkir: Dragonstorm 323 normal common 1 104326 970e11f0-337a-46b5-9bff-4bcb7843ed3a 0.1 false false near_mint en USD
80 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
81 Tempest Hawk TDM Tarkir: Dragonstorm 31 normal common 1 104587 422f9453-ab12-4e3c-8c51-be87391395a1 0.66 false false near_mint en USD
82 Eshki Dragonclaw TDM Tarkir: Dragonstorm 356 normal rare 1 104877 aafaa59e-87e1-4953-8c04-8e7a3a509827 0.44 false false near_mint en USD
83 Lotuslight Dancers TDM Tarkir: Dragonstorm 363 normal rare 1 104751 79dc69dc-6245-43fc-95a2-85b2c2957182 0.32 false false near_mint en USD
84 Rakshasa's Bargain TDM Tarkir: Dragonstorm 214 normal uncommon 1 104299 5c409f4f-3b2c-4c33-b850-55b2a46f51ca 0.32 false false near_mint en USD
85 Glacial Dragonhunt TDM Tarkir: Dragonstorm 188 normal uncommon 1 104814 95994c88-e404-4a4f-8be6-b99d703d4609 0.1 false false near_mint en USD
86 Elspeth, Storm Slayer TDM Tarkir: Dragonstorm 11 normal mythic 1 104311 73a065e3-b530-4e62-ab3c-4f6f908184ec 39.69 false false near_mint en USD
87 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
88 Sunset Strikemaster TDM Tarkir: Dragonstorm 126 normal uncommon 2 104394 f8f1a2f2-526d-4b2c-985b-0acfdc21a2ee 0.17 false false near_mint en USD

View File

@@ -10,7 +10,7 @@ from pathlib import Path
from app.routes import routes from app.routes import routes
from app.db.database import init_db, SessionLocal from app.db.database import init_db, SessionLocal
from app.services.service_manager import ServiceManager from app.services.service_manager import ServiceManager
from app.models.tcgplayer_products import refresh_view from app.models.tcgplayer_products import MostRecentTCGPlayerPrice
# Configure logging # Configure logging
log_file = Path("app.log") log_file = Path("app.log")
@@ -58,17 +58,19 @@ async def lifespan(app: FastAPI):
# Get a database session # Get a database session
db = SessionLocal() db = SessionLocal()
try: try:
data_init_service = service_manager.get_service('data_initialization') #data_init_service = service_manager.get_service('data_initialization')
# data_init = await data_init_service.initialize_data(db, game_ids=[1, 3], use_cache=False, init_categories=False, init_products=True, init_groups=False, init_archived_prices=False, init_mtgjson=False, archived_prices_start_date="2024-03-05", archived_prices_end_date="2025-04-17") #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-05-22", archived_prices_end_date="2025-05-23")
# logger.info(f"Data initialization results: {data_init}") #logger.info(f"Data initialization results: {data_init}")
data_init = await data_init_service.initialize_data(db, game_ids=[1], use_cache=False, init_categories=True, init_groups=True, init_products=True, init_archived_prices=True, archived_prices_start_date="2025-04-22", archived_prices_end_date="2025-04-24", init_mtgjson=True) # Update most recent prices
#refresh_view(db) #MostRecentTCGPlayerPrice.update_most_recent_prices(db)
logger.info("Most recent prices updated successfully")
# Create default customer, vendor, and marketplace # Create default customer, vendor, and marketplace
#inv_data_init = await data_init_service.initialize_inventory_data(db) #inv_data_init = await data_init_service.initialize_inventory_data(db)
#logger.info(f"Inventory data initialization results: {inv_data_init}")
# Start the scheduler # Start the scheduler
scheduler = service_manager.get_service('scheduler') scheduler = service_manager.get_service('scheduler')
#await scheduler.refresh_tcgplayer_inventory_table(db)
await scheduler.start_scheduled_tasks(db) await scheduler.start_scheduled_tasks(db)
logger.info("Scheduler started successfully") logger.info("Scheduler started successfully")
@@ -113,6 +115,46 @@ async def read_app_js():
raise HTTPException(status_code=404, detail="App.js file not found") raise HTTPException(status_code=404, detail="App.js file not found")
return FileResponse(js_path) 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 # Configure CORS with specific origins in production
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,

View File

@@ -29,6 +29,7 @@ from app.models.tcgplayer_order import (
) )
from app.models.tcgplayer_inventory import TCGPlayerInventory from app.models.tcgplayer_inventory import TCGPlayerInventory
from app.models.manabox_import_staging import ManaboxImportStaging from app.models.manabox_import_staging import ManaboxImportStaging
from app.models.pricing import PricingEvent
__all__ = [ __all__ = [
@@ -56,5 +57,6 @@ __all__ = [
'TCGPlayerOrderProduct', 'TCGPlayerOrderProduct',
'TCGPlayerOrderRefund', 'TCGPlayerOrderRefund',
'TCGPlayerPriceHistory', 'TCGPlayerPriceHistory',
'MostRecentTCGPlayerPrice' 'MostRecentTCGPlayerPrice',
'PricingEvent'
] ]

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, CheckConstraint from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, CheckConstraint, Index, Boolean, Table, UniqueConstraint
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.db.database import Base from app.db.database import Base
from sqlalchemy import event from sqlalchemy import event
@@ -7,6 +7,17 @@ from sqlalchemy import func
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
from datetime import datetime from datetime import datetime
from app.models.critical_error_log import CriticalErrorLog 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): class PhysicalItem(Base):
__tablename__ = "physical_items" __tablename__ = "physical_items"
@@ -38,21 +49,32 @@ class PhysicalItem(Base):
sku = relationship("MTGJSONSKU", back_populates="physical_items", primaryjoin="PhysicalItem.tcgplayer_sku_id == MTGJSONSKU.tcgplayer_sku_id") sku = relationship("MTGJSONSKU", back_populates="physical_items", primaryjoin="PhysicalItem.tcgplayer_sku_id == MTGJSONSKU.tcgplayer_sku_id")
product_direct = relationship("TCGPlayerProduct", product_direct = relationship("TCGPlayerProduct",
back_populates="physical_items_direct", back_populates="physical_items_direct",
primaryjoin="PhysicalItem.tcgplayer_product_id == foreign(TCGPlayerProduct.tcgplayer_product_id)") primaryjoin="PhysicalItem.tcgplayer_product_id == foreign(TCGPlayerProduct.tcgplayer_product_id)", uselist=False)
inventory_item = relationship("InventoryItem", uselist=False, back_populates="physical_item") inventory_item = relationship("InventoryItem", uselist=False, back_populates="physical_item")
transaction_items = relationship("TransactionItem", 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 @hybrid_property
def products(self): def products(self):
""" if self.sku and self.sku.product:
Dynamically resolve the associated TCGPlayerProduct(s): return self.sku.product
- If the SKU is set, return all linked products. elif self.product_direct:
- Else, return a list containing a single product from direct link. return self.product_direct
""" else:
# TODO IS THIS EVEN CORRECT OH GOD return None
if self.sku and self.sku.products:
return self.sku.products # This is a list of TCGPlayerProduct
return [self.product_direct] if self.product_direct else []
class InventoryItem(Base): class InventoryItem(Base):
__tablename__ = "inventory_items" __tablename__ = "inventory_items"
@@ -69,7 +91,8 @@ class InventoryItem(Base):
physical_item = relationship("PhysicalItem", back_populates="inventory_item") physical_item = relationship("PhysicalItem", back_populates="inventory_item")
parent = relationship("InventoryItem", remote_side=[id], back_populates="children") parent = relationship("InventoryItem", remote_side=[id], back_populates="children")
children = relationship("InventoryItem", back_populates="parent", overlaps="parent") children = relationship("InventoryItem", back_populates="parent", overlaps="parent")
marketplace_listings = relationship("MarketplaceListing", back_populates="inventory_item") marketplace_listing = relationship("MarketplaceListing", back_populates="inventory_item")
transaction_items = relationship("TransactionItem", back_populates="inventory_item")
@property @property
def products(self): def products(self):
@@ -78,7 +101,7 @@ class InventoryItem(Base):
Returns: Returns:
list[TCGPlayerProduct] or [] if no physical item or no linked products. list[TCGPlayerProduct] or [] if no physical item or no linked products.
""" """
return self.physical_item.product if self.physical_item else [] return self.physical_item.products if self.physical_item else None
def soft_delete(self, timestamp=None): def soft_delete(self, timestamp=None):
if not timestamp: if not timestamp:
@@ -87,92 +110,68 @@ class InventoryItem(Base):
for child in self.children: for child in self.children:
child.soft_delete(timestamp) child.soft_delete(timestamp)
class Box(PhysicalItem):
__tablename__ = "boxes"
class SealedBox(PhysicalItem):
__tablename__ = "sealed_boxes"
id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True) id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True)
case_id = Column(Integer, ForeignKey("sealed_cases.id"), nullable=True)
expected_value = Column(Float) expected_value = Column(Float)
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': 'sealed_box' 'polymorphic_identity': 'box'
} }
# Relationships class Case(PhysicalItem):
case = relationship("SealedCase", back_populates="boxes", foreign_keys=[case_id]) __tablename__ = "cases"
open_event = relationship("OpenEvent", uselist=False, back_populates="sealed_box")
class SealedCase(PhysicalItem):
__tablename__ = "sealed_cases"
id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True) id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True)
expected_value = Column(Float) expected_value = Column(Float)
num_boxes = Column(Integer) num_boxes = Column(Integer)
__mapper_args__ = { __mapper_args__ = {
'polymorphic_identity': 'sealed_case' 'polymorphic_identity': 'case'
} }
# Relationships class Card(PhysicalItem):
boxes = relationship("SealedBox", back_populates="case", foreign_keys=[SealedBox.case_id]) __tablename__ = "cards"
open_event = relationship("OpenEvent", uselist=False, back_populates="sealed_case")
id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True)
__mapper_args__ = {
'polymorphic_identity': 'card'
}
class OpenEvent(Base): class OpenEvent(Base):
__tablename__ = "open_events" __tablename__ = "open_events"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
sealed_case_id = Column(Integer, ForeignKey("sealed_cases.id"), nullable=True) source_item_id = Column(Integer, ForeignKey("physical_items.id"))
sealed_box_id = Column(Integer, ForeignKey("sealed_boxes.id"), nullable=True)
open_date = Column(DateTime(timezone=True)) open_date = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
deleted_at = Column(DateTime(timezone=True), nullable=True) deleted_at = Column(DateTime(timezone=True), nullable=True)
# Relationships __table_args__ = (
sealed_case = relationship("SealedCase", back_populates="open_event") UniqueConstraint("source_item_id", name="uq_openevent_one_per_source"),
sealed_box = relationship("SealedBox", back_populates="open_event") )
resulting_boxes = relationship("OpenBox", back_populates="open_event")
resulting_cards = relationship("OpenCard", back_populates="open_event")
class OpenCard(PhysicalItem):
__tablename__ = "open_cards"
id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True)
open_event_id = Column(Integer, ForeignKey("open_events.id"))
box_id = Column(Integer, ForeignKey("open_boxes.id"), nullable=True)
__mapper_args__ = {
'polymorphic_identity': 'open_card'
}
# Relationships # Relationships
open_event = relationship("OpenEvent", back_populates="resulting_cards") source_item = relationship(
box = relationship("OpenBox", back_populates="cards", foreign_keys=[box_id]) "PhysicalItem",
back_populates="source_open_events",
class OpenBox(PhysicalItem): foreign_keys=[source_item_id]
__tablename__ = "open_boxes" )
resulting_items = relationship(
id = Column(Integer, ForeignKey('physical_items.id'), primary_key=True) "PhysicalItem",
open_event_id = Column(Integer, ForeignKey("open_events.id")) secondary=open_event_resulting_items,
sealed_box_id = Column(Integer, ForeignKey("sealed_boxes.id")) back_populates="resulting_open_events"
)
__mapper_args__ = {
'polymorphic_identity': 'open_box'
}
# Relationships
open_event = relationship("OpenEvent", back_populates="resulting_boxes")
sealed_box = relationship("SealedBox", foreign_keys=[sealed_box_id])
cards = relationship("OpenCard", back_populates="box", foreign_keys=[OpenCard.box_id])
class SealedExpectedValue(Base): class SealedExpectedValue(Base):
__tablename__ = "sealed_expected_values" __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) id = Column(Integer, primary_key=True, index=True)
tcgplayer_product_id = Column(Integer, nullable=False) tcgplayer_product_id = Column(Integer, nullable=False)
@@ -185,64 +184,56 @@ class SealedExpectedValue(Base):
product = relationship( product = relationship(
"TCGPlayerProduct", "TCGPlayerProduct",
primaryjoin="SealedExpectedValue.tcgplayer_product_id == foreign(TCGPlayerProduct.tcgplayer_product_id)", primaryjoin="SealedExpectedValue.tcgplayer_product_id == foreign(TCGPlayerProduct.tcgplayer_product_id)",
viewonly=True, viewonly=True)
backref="sealed_expected_values"
)
# helper for ev # helper for ev
def assign_expected_value(target, session): #def assign_expected_value(target, session):
products = target.product # uses hybrid property # products = target.products
if not products: # if not products:
raise ValueError(f"No product found for item ID {target.id}") # raise ValueError(f"No product found for item ID {target.id}")
if len(products) > 1: # if len(products) > 1:
product_names = [p.name for p in products] # product_names = [p.name for p in products]
critical_error = CriticalErrorLog( # critical_error = CriticalErrorLog(
error_type="multiple_products_found", # error_type="multiple_products_found",
error_message=f"Multiple products found when assigning expected value for item ID {target.id} product names {product_names}" # error_message=f"Multiple products found when assigning expected value for item ID {target.id} product names {product_names}"
) # )
session.add(critical_error) # session.add(critical_error)
session.commit() # session.commit()
raise ValueError(f"Multiple products found when assigning expected value for item ID {target.id} product names {product_names}") # 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 # product_id = products[0].tcgplayer_product_id # reliable lookup key
expected_value_entry = session.query(SealedExpectedValue).filter( # expected_value_entry = session.query(SealedExpectedValue).filter(
SealedExpectedValue.tcgplayer_product_id == product_id, # SealedExpectedValue.tcgplayer_product_id == product_id,
SealedExpectedValue.deleted_at == None # SealedExpectedValue.deleted_at == None
).order_by(SealedExpectedValue.created_at.desc()).first() # ).order_by(SealedExpectedValue.created_at.desc()).first()
if expected_value_entry: # if expected_value_entry:
target.expected_value = expected_value_entry.expected_value # target.expected_value = expected_value_entry.expected_value
else: # else:
critical_error = CriticalErrorLog( # critical_error = CriticalErrorLog(
error_type="no_expected_value_found", # error_type="no_expected_value_found",
error_message=f"No expected value found for product {products[0].name}" # error_message=f"No expected value found for product {products[0].name}"
) # )
session.add(critical_error) # session.add(critical_error)
session.commit() # session.commit()
raise ValueError(f"No expected value found for product {products[0].name}") # raise ValueError(f"No expected value found for product {products[0].name}")
# event listeners # event listeners
@event.listens_for(SealedBox, 'before_insert') #@event.listens_for(InventoryItem, 'before_insert')
def sealed_box_before_insert(mapper, connection, target): #def ev_before_insert(mapper, connection, target):
session = Session.object_session(target) # session = Session.object_session(target)
if session: # if session:
assign_expected_value(target, session) # assign_expected_value(target, session)
@event.listens_for(SealedCase, 'before_insert')
def sealed_case_before_insert(mapper, connection, target):
session = Session.object_session(target)
if session:
assign_expected_value(target, session)
class TransactionItem(Base): class TransactionItem(Base):
__tablename__ = "transaction_items" __tablename__ = "transaction_items"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
transaction_id = Column(Integer, ForeignKey("transactions.id")) transaction_id = Column(Integer, ForeignKey("transactions.id"))
physical_item_id = Column(Integer, ForeignKey("physical_items.id")) inventory_item_id = Column(Integer, ForeignKey("inventory_items.id"))
unit_price = Column(Float, nullable=False) unit_price = Column(Float, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
@@ -250,7 +241,7 @@ class TransactionItem(Base):
# Relationships # Relationships
transaction = relationship("Transaction", back_populates="transaction_items") transaction = relationship("Transaction", back_populates="transaction_items")
physical_item = relationship("PhysicalItem", back_populates="transaction_items") inventory_item = relationship("InventoryItem", back_populates="transaction_items")
class Vendor(Base): class Vendor(Base):
__tablename__ = "vendors" __tablename__ = "vendors"
@@ -261,6 +252,9 @@ class Vendor(Base):
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
deleted_at = Column(DateTime(timezone=True), nullable=True) deleted_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
transactions = relationship("Transaction", back_populates="vendors")
class Customer(Base): class Customer(Base):
__tablename__ = "customers" __tablename__ = "customers"
@@ -269,6 +263,10 @@ class Customer(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
deleted_at = Column(DateTime(timezone=True), nullable=True) deleted_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
transactions = relationship("Transaction", back_populates="customers")
class Transaction(Base): class Transaction(Base):
__tablename__ = "transactions" __tablename__ = "transactions"
@@ -286,6 +284,9 @@ class Transaction(Base):
# Relationships # Relationships
transaction_items = relationship("TransactionItem", back_populates="transaction") 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): class Marketplace(Base):
__tablename__ = "marketplaces" __tablename__ = "marketplaces"
@@ -298,20 +299,23 @@ class Marketplace(Base):
# Relationships # Relationships
listings = relationship("MarketplaceListing", back_populates="marketplace") listings = relationship("MarketplaceListing", back_populates="marketplace")
transactions = relationship("Transaction", back_populates="marketplaces")
class MarketplaceListing(Base): class MarketplaceListing(Base):
__tablename__ = "marketplace_listings" __tablename__ = "marketplace_listings"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
inventory_item_id = Column(Integer, ForeignKey("inventory_items.id"), nullable=False) inventory_item_id = Column(Integer, ForeignKey("inventory_items.id"), nullable=False)
marketplace_id = Column(Integer, ForeignKey("marketplaces.id"), nullable=False) marketplace_id = Column(Integer, ForeignKey("marketplaces.id"), nullable=False)
listing_date = Column(DateTime(timezone=True)) 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) delisting_date = Column(DateTime(timezone=True), nullable=True)
listed_price = Column(Float)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
deleted_at = Column(DateTime(timezone=True), nullable=True) deleted_at = Column(DateTime(timezone=True), nullable=True)
# Relationships # Relationships
inventory_item = relationship("InventoryItem", back_populates="marketplace_listings") inventory_item = relationship("InventoryItem", back_populates="marketplace_listing")
marketplace = relationship("Marketplace", back_populates="listings") marketplace = relationship("Marketplace", back_populates="listings")
recommended_price = relationship("PricingEvent", foreign_keys=[recommended_price_id])
listed_price = relationship("PricingEvent", foreign_keys=[listed_price_id])

View File

@@ -8,6 +8,7 @@ class ManaboxImportStaging(Base):
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
file_id = Column(Integer, ForeignKey("files.id")) file_id = Column(Integer, ForeignKey("files.id"))
tcgplayer_product_id = Column(Integer)
tcgplayer_sku_id = Column(Integer) tcgplayer_sku_id = Column(Integer)
quantity = Column(Integer) quantity = Column(Integer)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())

29
app/models/pricing.py Normal file
View 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
)

View File

@@ -1,12 +1,13 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.db.database import Base from app.db.database import Base
from sqlalchemy.orm import relationship
class TCGPlayerInventory(Base): class TCGPlayerInventory(Base):
__tablename__ = "tcgplayer_inventory" __tablename__ = "tcgplayer_inventory"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
tcgplayer_sku_id = Column(Integer, ForeignKey("mtgjson_skus.tcgplayer_sku_id"), unique=True, index=True) tcgplayer_sku_id = Column(Integer, unique=True, index=True)
product_line = Column(String) product_line = Column(String)
set_name = Column(String) set_name = Column(String)
product_name = Column(String) product_name = Column(String)
@@ -24,3 +25,37 @@ class TCGPlayerInventory(Base):
photo_url = Column(String) photo_url = Column(String)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=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)

View File

@@ -2,43 +2,44 @@ from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Table, Flo
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.db.database import Base from app.db.database import Base
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.orm import Session
from sqlalchemy import and_
# ============================================================================= # =============================================================================
# Core Models # Core Models
# ============================================================================= # =============================================================================
class MTGJSONSKU(Base): class MTGJSONSKU(Base):
"""Represents the most granular level of card identification.
THIS WORKS EVEN IF ITS A BOX SOMEHOW
"""
__tablename__ = "mtgjson_skus" __tablename__ = "mtgjson_skus"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
mtgjson_uuid = Column(String, ForeignKey("mtgjson_cards.mtgjson_uuid"), unique=True, index=True) mtgjson_uuid = Column(String, ForeignKey("mtgjson_cards.mtgjson_uuid"), index=True)
tcgplayer_sku_id = Column(Integer, index=True, unique=True) tcgplayer_sku_id = Column(Integer, index=True, unique=True)
tcgplayer_product_id = Column(Integer, nullable=False) tcgplayer_product_id = Column(Integer, nullable=False)
normalized_printing = Column(String, nullable=False) # normalized for FK normalized_printing = Column(String, nullable=False)
condition = Column(String) # for boxes, condition = unopened condition = Column(String)
finish = Column(String, nullable=True) # TODO MAKE THESE ENUMS finish = Column(String, nullable=True)
language = Column(String) language = Column(String)
printing = Column(String) # original unnormalized field ##### for boxes, printing = NON FOIL printing = Column(String)
created_at = Column(DateTime(timezone=True), server_default=func.now()) 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), onupdate=func.now())
# Foreign key to tcgplayer_products via composite key
__table_args__ = ( __table_args__ = (
ForeignKeyConstraint( ForeignKeyConstraint(
["tcgplayer_product_id", "normalized_printing"], ["tcgplayer_product_id", "normalized_printing"],
["tcgplayer_products.tcgplayer_product_id", "tcgplayer_products.normalized_sub_type_name"], ["tcgplayer_products.tcgplayer_product_id", "tcgplayer_products.normalized_sub_type_name"],
name="fk_sku_to_product_composite" name="fk_sku_to_product_composite"
), ),
Index('idx_sku_product_printing', 'tcgplayer_product_id', 'normalized_printing', unique=True), Index('idx_sku_product_printing', 'tcgplayer_product_id', 'normalized_printing'),
) )
product = relationship("TCGPlayerProduct", back_populates="skus") product = relationship("TCGPlayerProduct", back_populates="skus")
physical_items = relationship("PhysicalItem", back_populates="sku", primaryjoin="PhysicalItem.tcgplayer_sku_id == MTGJSONSKU.tcgplayer_sku_id") physical_items = relationship(
"PhysicalItem",
card = relationship("MTGJSONCard", back_populates="skus", primaryjoin="MTGJSONCard.mtgjson_uuid == MTGJSONSKU.mtgjson_uuid") back_populates="sku",
primaryjoin="PhysicalItem.tcgplayer_sku_id == MTGJSONSKU.tcgplayer_sku_id"
)
card = relationship("MTGJSONCard", back_populates="skus")
class MTGJSONCard(Base): class MTGJSONCard(Base):
@@ -72,13 +73,17 @@ class MTGJSONCard(Base):
scryfall_card_back_id = Column(String, nullable=True) scryfall_card_back_id = Column(String, nullable=True)
scryfall_oracle_id = Column(String, nullable=True) scryfall_oracle_id = Column(String, nullable=True)
scryfall_illustration_id = Column(String, nullable=True) scryfall_illustration_id = Column(String, nullable=True)
tcgplayer_product_id = Column(String, nullable=True) tcgplayer_product_id = Column(Integer, nullable=True)
tcgplayer_etched_product_id = Column(String, nullable=True) tcgplayer_etched_product_id = Column(Integer, nullable=True)
tnt_id = Column(String, nullable=True) tnt_id = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now()) 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), onupdate=func.now())
skus = relationship("MTGJSONSKU", back_populates="card", primaryjoin="MTGJSONSKU.mtgjson_uuid == MTGJSONCard.mtgjson_uuid") __table_args__ = (
UniqueConstraint("mtgjson_uuid", name="uq_card_mtgjson_uuid"),
)
skus = relationship("MTGJSONSKU", back_populates="card")
class TCGPlayerProduct(Base): class TCGPlayerProduct(Base):
@@ -117,13 +122,16 @@ class TCGPlayerProduct(Base):
# Enforce uniqueness for composite key # Enforce uniqueness for composite key
__table_args__ = ( __table_args__ = (
UniqueConstraint("tcgplayer_product_id", "normalized_sub_type_name", name="uq_product_subtype"), 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 # Backref to SKUs that link via composite FK
skus = relationship("MTGJSONSKU", back_populates="product") skus = relationship("MTGJSONSKU", back_populates="product")
physical_items_direct = relationship("PhysicalItem", physical_items_direct = relationship("PhysicalItem",
back_populates="product_direct", back_populates="product_direct",
primaryjoin="foreign(TCGPlayerProduct.tcgplayer_product_id) == PhysicalItem.tcgplayer_product_id") primaryjoin="foreign(TCGPlayerProduct.tcgplayer_product_id) == PhysicalItem.tcgplayer_product_id",
viewonly=True,
uselist=False)
category = relationship("TCGPlayerCategory", back_populates="products") category = relationship("TCGPlayerCategory", back_populates="products")
group = relationship("TCGPlayerGroup", back_populates="products") group = relationship("TCGPlayerGroup", back_populates="products")
price_history = relationship("TCGPlayerPriceHistory", price_history = relationship("TCGPlayerPriceHistory",
@@ -136,6 +144,11 @@ class TCGPlayerProduct(Base):
primaryjoin="and_(foreign(TCGPlayerProduct.tcgplayer_product_id) == MostRecentTCGPlayerPrice.product_id, " primaryjoin="and_(foreign(TCGPlayerProduct.tcgplayer_product_id) == MostRecentTCGPlayerPrice.product_id, "
"foreign(TCGPlayerProduct.sub_type_name) == MostRecentTCGPlayerPrice.sub_type_name)") "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 # Supporting Models
# ============================================================================= # =============================================================================
@@ -213,9 +226,7 @@ class TCGPlayerPriceHistory(Base):
class MostRecentTCGPlayerPrice(Base): class MostRecentTCGPlayerPrice(Base):
"""Represents the most recent price for a product. """Represents the most recent price for a product.
THIS ISNT A MATERIALIZED VIEW ANYMORE FUCK IT
This is a materialized view that contains the latest price data for each product.
It is maintained through triggers on the price_history table.
""" """
__tablename__ = "most_recent_tcgplayer_price" __tablename__ = "most_recent_tcgplayer_price"
@@ -233,50 +244,52 @@ class MostRecentTCGPlayerPrice(Base):
__table_args__ = ( __table_args__ = (
Index('idx_most_recent_price_product_subtype', 'product_id', 'sub_type_name', unique=True), Index('idx_most_recent_price_product_subtype', 'product_id', 'sub_type_name', unique=True),
{'info': {'is_view': True}} )
)
product = relationship("TCGPlayerProduct", product = relationship("TCGPlayerProduct",
back_populates="most_recent_tcgplayer_price", back_populates="most_recent_tcgplayer_price",
primaryjoin="and_(MostRecentTCGPlayerPrice.product_id == foreign(TCGPlayerProduct.tcgplayer_product_id), " primaryjoin="and_(MostRecentTCGPlayerPrice.product_id == foreign(TCGPlayerProduct.tcgplayer_product_id), "
"MostRecentTCGPlayerPrice.sub_type_name == foreign(TCGPlayerProduct.sub_type_name))") "MostRecentTCGPlayerPrice.sub_type_name == foreign(TCGPlayerProduct.sub_type_name))")
def create_most_recent_price_view(): @classmethod
"""Creates the materialized view for most recent prices.""" def update_most_recent_prices(cls, db: Session) -> None:
return DDL(""" """Update the most recent prices from the price history table."""
CREATE MATERIALIZED VIEW IF NOT EXISTS most_recent_tcgplayer_price AS # Delete all existing records
SELECT DISTINCT ON (ph.product_id, ph.sub_type_name) db.query(cls).delete()
ph.id,
ph.product_id,
ph.sub_type_name,
ph.date,
ph.low_price,
ph.mid_price,
ph.high_price,
ph.market_price,
ph.direct_low_price,
ph.created_at,
ph.updated_at
FROM tcgplayer_price_history ph
ORDER BY ph.product_id, ph.sub_type_name, ph.date DESC;
CREATE UNIQUE INDEX IF NOT EXISTS idx_most_recent_price_product_subtype
ON most_recent_tcgplayer_price (product_id, sub_type_name);
""")
# Register the view creation with SQLAlchemy # Get the most recent price for each product and sub_type_name
event.listen( subquery = db.query(
MostRecentTCGPlayerPrice.__table__, TCGPlayerPriceHistory.product_id,
'after_create', TCGPlayerPriceHistory.sub_type_name,
create_most_recent_price_view() func.max(TCGPlayerPriceHistory.date).label('max_date')
) ).group_by(
TCGPlayerPriceHistory.product_id,
TCGPlayerPriceHistory.sub_type_name
).subquery()
# Add a method to refresh the view # Join with price history to get the full records
@classmethod latest_prices = db.query(TCGPlayerPriceHistory).join(
def refresh_view(cls, session): subquery,
"""Refreshes the materialized view.""" and_(
session.execute(text("REFRESH MATERIALIZED VIEW CONCURRENTLY most_recent_tcgplayer_price")) TCGPlayerPriceHistory.product_id == subquery.c.product_id,
session.commit() TCGPlayerPriceHistory.sub_type_name == subquery.c.sub_type_name,
TCGPlayerPriceHistory.date == subquery.c.max_date
)
).all()
MostRecentTCGPlayerPrice.refresh_view = refresh_view # 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()

View File

@@ -1,10 +1,16 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from datetime import datetime
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_, func
from app.db.database import get_db from app.db.database import get_db
from app.services.service_manager import ServiceManager from app.services.service_manager import ServiceManager
from app.contexts.inventory_item import InventoryItemContextFactory from app.contexts.inventory_item import InventoryItemContextFactory
from app.schemas.transaction import PurchaseTransactionCreate, SaleTransactionCreate, TransactionResponse 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 typing import List
from fastapi.responses import StreamingResponse
router = APIRouter(prefix="/inventory") router = APIRouter(prefix="/inventory")
service_manager = ServiceManager() service_manager = ServiceManager()
@@ -155,6 +161,14 @@ async def create_vendor(
vendor = await inventory_service.create_vendor(db, vendor_name) vendor = await inventory_service.create_vendor(db, vendor_name)
return vendor 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") @router.post("/marketplaces")
async def create_marketplace( async def create_marketplace(
marketplace_name: str, marketplace_name: str,
@@ -163,3 +177,312 @@ async def create_marketplace(
inventory_service = service_manager.get_service("inventory") inventory_service = service_manager.get_service("inventory")
marketplace = await inventory_service.create_marketplace(db, marketplace_name) marketplace = await inventory_service.create_marketplace(db, marketplace_name)
return marketplace 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))

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Form from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Form, BackgroundTasks
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.database import get_db from app.db.database import get_db
from app.services.service_manager import ServiceManager from app.services.service_manager import ServiceManager
@@ -50,6 +50,7 @@ def is_valid_csv(file: UploadFile) -> tuple[bool, str]:
@router.post("/process-csv") @router.post("/process-csv")
async def process_manabox_csv( async def process_manabox_csv(
background_tasks: BackgroundTasks,
file: UploadFile = File(...), file: UploadFile = File(...),
source: str = Form(...), source: str = Form(...),
description: str = Form(...), description: str = Form(...),
@@ -72,7 +73,7 @@ async def process_manabox_csv(
manabox_service = service_manager.get_service("manabox") manabox_service = service_manager.get_service("manabox")
success = await manabox_service.process_manabox_csv(db, content, metadata) success = await manabox_service.process_manabox_csv(db, content, metadata, background_tasks)
if not success: if not success:
raise HTTPException(status_code=400, detail="Failed to process CSV file") raise HTTPException(status_code=400, detail="Failed to process CSV file")

View File

@@ -220,6 +220,7 @@ async def print_pirate_ship_label(
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to print Pirate Ship label: {str(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") @router.post("/process-manabox-csv")
async def process_manabox_csv( async def process_manabox_csv(
file: UploadFile = File(...), file: UploadFile = File(...),

0
app/schemas/inventory.py Normal file
View File

View File

@@ -1,12 +1,12 @@
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime from datetime import datetime
from app.models.tcgplayer_products import TCGPlayerProduct
class PurchaseItem(BaseModel): class PurchaseItem(BaseModel):
product_id: int product_id: int
unit_price: float unit_price: float
quantity: int quantity: int
is_case: bool item_type: str
num_boxes: Optional[int] = None num_boxes: Optional[int] = None
# TODO: remove is_case and num_boxes, should derive from product_id # TODO: remove is_case and num_boxes, should derive from product_id
@@ -30,11 +30,11 @@ class SaleTransactionCreate(BaseModel):
class TransactionItemResponse(BaseModel): class TransactionItemResponse(BaseModel):
id: int id: int
transaction_id: int transaction_id: int
physical_item_id: int inventory_item_id: int
unit_price: float unit_price: float
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
deleted_at: Optional[datetime] = None
class TransactionResponse(BaseModel): class TransactionResponse(BaseModel):
id: int id: int
vendor_id: Optional[int] = None vendor_id: Optional[int] = None
@@ -46,4 +46,64 @@ class TransactionResponse(BaseModel):
transaction_notes: Optional[str] = None transaction_notes: Optional[str] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
deleted_at: Optional[datetime] = None
transaction_items: List[TransactionItemResponse] 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]

View File

@@ -14,6 +14,8 @@ from app.services.set_label_service import SetLabelService
from app.services.scheduler.scheduler_service import SchedulerService 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.order_management_service import OrderManagementService
from app.services.external_api.tcgplayer.tcgplayer_inventory_service import TCGPlayerInventoryService 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__ = [ __all__ = [
'BaseService', 'BaseService',
@@ -31,5 +33,7 @@ __all__ = [
'SetLabelService', 'SetLabelService',
'SchedulerService', 'SchedulerService',
'OrderManagementService', 'OrderManagementService',
'TCGPlayerInventoryService' 'TCGPlayerInventoryService',
'PricingService',
'MarketplaceListingService'
] ]

View File

@@ -12,6 +12,7 @@ from app.schemas.transaction import PurchaseTransactionCreate, PurchaseItem
from app.contexts.inventory_item import InventoryItemContextFactory from app.contexts.inventory_item import InventoryItemContextFactory
from app.models.tcgplayer_products import MTGJSONSKU, MTGJSONCard from app.models.tcgplayer_products import MTGJSONSKU, MTGJSONCard
from app.models.tcgplayer_products import TCGPlayerPriceHistory from app.models.tcgplayer_products import TCGPlayerPriceHistory
from app.models.critical_error_log import CriticalErrorLog
import csv import csv
import io import io
import logging import logging
@@ -349,7 +350,7 @@ class DataInitializationService(BaseService):
for price_data in archived_prices_data.get("results", []): for price_data in archived_prices_data.get("results", []):
try: try:
# Get the subtype name from the price data # Get the subtype name from the price data
sub_type_name = price_data.get("subTypeName", "other") sub_type_name = price_data.get("subTypeName", "None")
# First try to find product with the requested subtype # First try to find product with the requested subtype
product = db.query(TCGPlayerProduct).filter( product = db.query(TCGPlayerProduct).filter(
@@ -521,121 +522,179 @@ class DataInitializationService(BaseService):
} }
async def sync_mtgjson_identifiers(self, db: Session, identifiers_data: List[dict]) -> int: async def sync_mtgjson_identifiers(self, db: Session, identifiers_data: List[dict]) -> int:
"""Sync MTGJSON identifiers data to the database"""
count = 0 count = 0
with db_transaction(db): with db_transaction(db):
# Load all existing UUIDs once
existing_cards = {
card.mtgjson_uuid: card
for card in db.query(MTGJSONCard).all()
}
new_cards = []
for card_data in identifiers_data: for card_data in identifiers_data:
if not isinstance(card_data, dict): if not isinstance(card_data, dict):
logger.debug(f"Skipping non-dict item: {card_data}") logger.debug(f"Skipping non-dict item: {card_data}")
continue continue
existing_card = db.query(MTGJSONCard).filter(MTGJSONCard.mtgjson_uuid == card_data.get("uuid")).first() uuid = card_data.get("uuid")
if existing_card: identifiers = card_data.get("identifiers", {})
# Update existing card
for key, value in { if uuid in existing_cards:
card = existing_cards[uuid]
updates = {
"name": card_data.get("name"), "name": card_data.get("name"),
"set_code": card_data.get("setCode"), "set_code": card_data.get("setCode"),
"abu_id": card_data.get("identifiers", {}).get("abuId"), "abu_id": identifiers.get("abuId"),
"card_kingdom_etched_id": card_data.get("identifiers", {}).get("cardKingdomEtchedId"), "card_kingdom_etched_id": identifiers.get("cardKingdomEtchedId"),
"card_kingdom_foil_id": card_data.get("identifiers", {}).get("cardKingdomFoilId"), "card_kingdom_foil_id": identifiers.get("cardKingdomFoilId"),
"card_kingdom_id": card_data.get("identifiers", {}).get("cardKingdomId"), "card_kingdom_id": identifiers.get("cardKingdomId"),
"cardsphere_id": card_data.get("identifiers", {}).get("cardsphereId"), "cardsphere_id": identifiers.get("cardsphereId"),
"cardsphere_foil_id": card_data.get("identifiers", {}).get("cardsphereFoilId"), "cardsphere_foil_id": identifiers.get("cardsphereFoilId"),
"cardtrader_id": card_data.get("identifiers", {}).get("cardtraderId"), "cardtrader_id": identifiers.get("cardtraderId"),
"csi_id": card_data.get("identifiers", {}).get("csiId"), "csi_id": identifiers.get("csiId"),
"mcm_id": card_data.get("identifiers", {}).get("mcmId"), "mcm_id": identifiers.get("mcmId"),
"mcm_meta_id": card_data.get("identifiers", {}).get("mcmMetaId"), "mcm_meta_id": identifiers.get("mcmMetaId"),
"miniaturemarket_id": card_data.get("identifiers", {}).get("miniaturemarketId"), "miniaturemarket_id": identifiers.get("miniaturemarketId"),
"mtg_arena_id": card_data.get("identifiers", {}).get("mtgArenaId"), "mtg_arena_id": identifiers.get("mtgArenaId"),
"mtgjson_foil_version_id": card_data.get("identifiers", {}).get("mtgjsonFoilVersionId"), "mtgjson_foil_version_id": identifiers.get("mtgjsonFoilVersionId"),
"mtgjson_non_foil_version_id": card_data.get("identifiers", {}).get("mtgjsonNonFoilVersionId"), "mtgjson_non_foil_version_id": identifiers.get("mtgjsonNonFoilVersionId"),
"mtgjson_v4_id": card_data.get("identifiers", {}).get("mtgjsonV4Id"), "mtgjson_v4_id": identifiers.get("mtgjsonV4Id"),
"mtgo_foil_id": card_data.get("identifiers", {}).get("mtgoFoilId"), "mtgo_foil_id": identifiers.get("mtgoFoilId"),
"mtgo_id": card_data.get("identifiers", {}).get("mtgoId"), "mtgo_id": identifiers.get("mtgoId"),
"multiverse_id": card_data.get("identifiers", {}).get("multiverseId"), "multiverse_id": identifiers.get("multiverseId"),
"scg_id": card_data.get("identifiers", {}).get("scgId"), "scg_id": identifiers.get("scgId"),
"scryfall_id": card_data.get("identifiers", {}).get("scryfallId"), "scryfall_id": identifiers.get("scryfallId"),
"scryfall_card_back_id": card_data.get("identifiers", {}).get("scryfallCardBackId"), "scryfall_card_back_id": identifiers.get("scryfallCardBackId"),
"scryfall_oracle_id": card_data.get("identifiers", {}).get("scryfallOracleId"), "scryfall_oracle_id": identifiers.get("scryfallOracleId"),
"scryfall_illustration_id": card_data.get("identifiers", {}).get("scryfallIllustrationId"), "scryfall_illustration_id": identifiers.get("scryfallIllustrationId"),
"tcgplayer_product_id": card_data.get("identifiers", {}).get("tcgplayerProductId"), "tcgplayer_product_id": identifiers.get("tcgplayerProductId"),
"tcgplayer_etched_product_id": card_data.get("identifiers", {}).get("tcgplayerEtchedProductId"), "tcgplayer_etched_product_id": identifiers.get("tcgplayerEtchedProductId"),
"tnt_id": card_data.get("identifiers", {}).get("tntId") "tnt_id": identifiers.get("tntId")
}.items(): }
setattr(existing_card, key, value)
for k, v in updates.items():
if getattr(card, k) != v:
setattr(card, k, v)
else: else:
new_card = MTGJSONCard( new_cards.append(MTGJSONCard(
mtgjson_uuid=card_data.get("uuid"), mtgjson_uuid=uuid,
name=card_data.get("name"), name=card_data.get("name"),
set_code=card_data.get("setCode"), set_code=card_data.get("setCode"),
abu_id=card_data.get("identifiers", {}).get("abuId"), abu_id=identifiers.get("abuId"),
card_kingdom_etched_id=card_data.get("identifiers", {}).get("cardKingdomEtchedId"), card_kingdom_etched_id=identifiers.get("cardKingdomEtchedId"),
card_kingdom_foil_id=card_data.get("identifiers", {}).get("cardKingdomFoilId"), card_kingdom_foil_id=identifiers.get("cardKingdomFoilId"),
card_kingdom_id=card_data.get("identifiers", {}).get("cardKingdomId"), card_kingdom_id=identifiers.get("cardKingdomId"),
cardsphere_id=card_data.get("identifiers", {}).get("cardsphereId"), cardsphere_id=identifiers.get("cardsphereId"),
cardsphere_foil_id=card_data.get("identifiers", {}).get("cardsphereFoilId"), cardsphere_foil_id=identifiers.get("cardsphereFoilId"),
cardtrader_id=card_data.get("identifiers", {}).get("cardtraderId"), cardtrader_id=identifiers.get("cardtraderId"),
csi_id=card_data.get("identifiers", {}).get("csiId"), csi_id=identifiers.get("csiId"),
mcm_id=card_data.get("identifiers", {}).get("mcmId"), mcm_id=identifiers.get("mcmId"),
mcm_meta_id=card_data.get("identifiers", {}).get("mcmMetaId"), mcm_meta_id=identifiers.get("mcmMetaId"),
miniaturemarket_id=card_data.get("identifiers", {}).get("miniaturemarketId"), miniaturemarket_id=identifiers.get("miniaturemarketId"),
mtg_arena_id=card_data.get("identifiers", {}).get("mtgArenaId"), mtg_arena_id=identifiers.get("mtgArenaId"),
mtgjson_foil_version_id=card_data.get("identifiers", {}).get("mtgjsonFoilVersionId"), mtgjson_foil_version_id=identifiers.get("mtgjsonFoilVersionId"),
mtgjson_non_foil_version_id=card_data.get("identifiers", {}).get("mtgjsonNonFoilVersionId"), mtgjson_non_foil_version_id=identifiers.get("mtgjsonNonFoilVersionId"),
mtgjson_v4_id=card_data.get("identifiers", {}).get("mtgjsonV4Id"), mtgjson_v4_id=identifiers.get("mtgjsonV4Id"),
mtgo_foil_id=card_data.get("identifiers", {}).get("mtgoFoilId"), mtgo_foil_id=identifiers.get("mtgoFoilId"),
mtgo_id=card_data.get("identifiers", {}).get("mtgoId"), mtgo_id=identifiers.get("mtgoId"),
multiverse_id=card_data.get("identifiers", {}).get("multiverseId"), multiverse_id=identifiers.get("multiverseId"),
scg_id=card_data.get("identifiers", {}).get("scgId"), scg_id=identifiers.get("scgId"),
scryfall_id=card_data.get("identifiers", {}).get("scryfallId"), scryfall_id=identifiers.get("scryfallId"),
scryfall_card_back_id=card_data.get("identifiers", {}).get("scryfallCardBackId"), scryfall_card_back_id=identifiers.get("scryfallCardBackId"),
scryfall_oracle_id=card_data.get("identifiers", {}).get("scryfallOracleId"), scryfall_oracle_id=identifiers.get("scryfallOracleId"),
scryfall_illustration_id=card_data.get("identifiers", {}).get("scryfallIllustrationId"), scryfall_illustration_id=identifiers.get("scryfallIllustrationId"),
tcgplayer_product_id=card_data.get("identifiers", {}).get("tcgplayerProductId"), tcgplayer_product_id=identifiers.get("tcgplayerProductId"),
tcgplayer_etched_product_id=card_data.get("identifiers", {}).get("tcgplayerEtchedProductId"), tcgplayer_etched_product_id=identifiers.get("tcgplayerEtchedProductId"),
tnt_id=card_data.get("identifiers", {}).get("tntId") tnt_id=identifiers.get("tntId")
) ))
db.add(new_card)
count += 1 count += 1
if new_cards:
db.bulk_save_objects(new_cards)
return count return count
async def sync_mtgjson_skus(self, db: Session, skus_data: dict) -> int: async def sync_mtgjson_skus(self, db: Session, skus_data: dict) -> int:
"""Sync MTGJSON SKUs data to the database"""
count = 0 count = 0
with db_transaction(db): sku_details_by_key = {}
for mtgjson_uuid, product_data in skus_data['data'].items():
for mtgjson_uuid, product_data in skus_data["data"].items():
for sku_data in product_data: for sku_data in product_data:
existing_record = db.query(MTGJSONSKU).filter(MTGJSONSKU.mtgjson_uuid == mtgjson_uuid).filter(MTGJSONSKU.tcgplayer_sku_id == sku_data.get("skuId")).first() sku_id = sku_data.get("skuId")
if existing_record: if sku_id is None or sku_id in sku_details_by_key:
# Update existing SKU continue # Skip if missing or already added
for key, value in {
sku_details_by_key[sku_id] = {
"mtgjson_uuid": mtgjson_uuid,
"tcgplayer_sku_id": sku_id,
"tcgplayer_product_id": sku_data.get("productId"), "tcgplayer_product_id": sku_data.get("productId"),
"printing": sku_data.get("printing"),
"normalized_printing": sku_data.get("printing", "").lower().replace(" ", "_").replace("non_foil", "normal") if sku_data.get("printing") else None,
"condition": sku_data.get("condition"), "condition": sku_data.get("condition"),
"finish": sku_data.get("finish"), "finish": sku_data.get("finish"),
"language": sku_data.get("language"), "language": sku_data.get("language"),
"printing": sku_data.get("printing"), }
"normalized_printing": sku_data.get("printing").lower().replace(" ", "_") if sku_data.get("printing") else None
}.items(): with db_transaction(db):
setattr(existing_record, key, value) db.flush()
valid_uuids = {uuid for (uuid,) in db.query(MTGJSONCard.mtgjson_uuid).all()}
valid_product_keys = {
(product.tcgplayer_product_id, product.normalized_sub_type_name)
for product in db.query(TCGPlayerProduct.tcgplayer_product_id, TCGPlayerProduct.normalized_sub_type_name)
}
existing_sku_ids = {
sku.tcgplayer_sku_id
for sku in db.query(MTGJSONSKU.tcgplayer_sku_id).all()
}
existing = {
(sku.mtgjson_uuid, sku.tcgplayer_sku_id): sku
for sku in db.query(MTGJSONSKU).all()
}
new_skus = []
for data in sku_details_by_key.values():
sku_id = data["tcgplayer_sku_id"]
if sku_id in existing_sku_ids:
continue
mtgjson_uuid = data["mtgjson_uuid"]
product_id = data["tcgplayer_product_id"]
normalized_printing = data["normalized_printing"]
if mtgjson_uuid not in valid_uuids:
continue
if (product_id, normalized_printing) not in valid_product_keys:
continue
key = (mtgjson_uuid, sku_id)
if key in existing:
record = existing[key]
for field, value in data.items():
if field not in ("mtgjson_uuid", "tcgplayer_sku_id") and getattr(record, field) != value:
setattr(record, field, value)
else: else:
new_sku = MTGJSONSKU( new_skus.append(MTGJSONSKU(**data))
mtgjson_uuid=mtgjson_uuid,
tcgplayer_sku_id=sku_data.get("skuId"),
tcgplayer_product_id=sku_data.get("productId"),
condition=sku_data.get("condition"),
finish=sku_data.get("finish"),
language=sku_data.get("language"),
printing=sku_data.get("printing"),
normalized_printing=sku_data.get("printing").lower().replace(" ", "_") if sku_data.get("printing") else None
)
db.add(new_sku)
count += 1 count += 1
if new_skus:
db.bulk_save_objects(new_skus)
return count return count
async def initialize_data( async def initialize_data(
self, self,
db: Session, db: Session,
@@ -693,19 +752,17 @@ class DataInitializationService(BaseService):
with db_transaction(db): with db_transaction(db):
logger.info("Initializing inventory data...") logger.info("Initializing inventory data...")
# set expected value # set expected value
product_id1 = db.query(TCGPlayerProduct).filter(TCGPlayerProduct.tcgplayer_sku == "562118").first().id
expected_value_box = SealedExpectedValue( expected_value_box = SealedExpectedValue(
product_id=product_id1, tcgplayer_product_id=619645,
expected_value=120.69 expected_value=136.42
) )
db.add(expected_value_box) db.add(expected_value_box)
db.flush() #db.flush()
product_id2 = db.query(TCGPlayerProduct).filter(TCGPlayerProduct.tcgplayer_sku == "562119").first().id #expected_value_case = SealedExpectedValue(
expected_value_case = SealedExpectedValue( # tcgplayer_product_id=562119,
product_id=product_id2, # expected_value=820.69
expected_value=820.69 #)
) #db.add(expected_value_case)
db.add(expected_value_case)
db.flush() db.flush()
inventory_service = self.get_service("inventory") inventory_service = self.get_service("inventory")
@@ -715,32 +772,38 @@ class DataInitializationService(BaseService):
transaction = await inventory_service.create_purchase_transaction(db, PurchaseTransactionCreate( transaction = await inventory_service.create_purchase_transaction(db, PurchaseTransactionCreate(
vendor_id=vendor.id, vendor_id=vendor.id,
transaction_date=datetime.now(), transaction_date=datetime.now(),
items=[PurchaseItem(product_id=product_id1, unit_price=100.69, quantity=1, is_case=False), items=[PurchaseItem(product_id=619645, unit_price=100, quantity=1, item_type="box")],
PurchaseItem(product_id=product_id2, unit_price=800.01, quantity=2, is_case=True, num_boxes=6)], transaction_notes="tdm real box test"
transaction_notes="Test Transaction: 1 case and 2 boxes of foundations" #PurchaseItem(product_id=562119, unit_price=800.01, quantity=2, is_case=True, num_boxes=6)],
#transaction_notes="Test Transaction: 1 case and 2 boxes of foundations"
)) ))
logger.info(f"Transaction created: {transaction}") logger.info(f"Transaction created: {transaction}")
case_num = 0 case_num = 0
for item in transaction.transaction_items: for item in transaction.transaction_items:
item = InventoryItemContextFactory(db).get_context(item.physical_item.inventory_item)
logger.info(f"Item: {item}") logger.info(f"Item: {item}")
if item.physical_item.item_type == "sealed_box": if item.inventory_item.physical_item.item_type == "box":
manabox_service = self.get_service("manabox") manabox_service = self.get_service("manabox")
file_path = 'app/data/test_data/manabox_test_file.csv' #file_path = 'app/data/test_data/manabox_test_file.csv'
file_path = 'app/data/test_data/dragon.csv'
file_bytes = open(file_path, 'rb').read() file_bytes = open(file_path, 'rb').read()
manabox_file = await manabox_service.process_manabox_csv(db, file_bytes, {"source": "test", "description": "test"}, wait=True) manabox_file = await manabox_service.process_manabox_csv(db, file_bytes, {"source": "test", "description": "test"}, wait=True)
# Ensure manabox_file is a list before passing it # Ensure manabox_file is a list before passing it
if not isinstance(manabox_file, list): if not isinstance(manabox_file, list):
manabox_file = [manabox_file] manabox_file = [manabox_file]
sealed_box_service = self.get_service("sealed_box") box_service = self.get_service("box")
sealed_box = sealed_box_service.get(db, item.physical_item.inventory_item.id) open_event = await box_service.open_box(db, item.inventory_item.physical_item, manabox_file)
success = await inventory_service.process_manabox_import_staging(db, manabox_file, sealed_box) # get all cards from box
logger.info(f"sealed box opening success: {success}") cards = open_event.resulting_items if open_event.resulting_items else []
elif item.physical_item.item_type == "sealed_case": marketplace_listing_service = self.get_service("marketplace_listing")
for card in cards:
logger.info(f"card: {card}")
# create marketplace listing
await marketplace_listing_service.create_marketplace_listing(db, card.inventory_item, marketplace)
elif item.inventory_item.physical_item.item_type == "case":
if case_num == 0: if case_num == 0:
logger.info(f"sealed case {case_num} opening...") logger.info(f"sealed case {case_num} opening...")
sealed_case_service = self.get_service("sealed_case") case_service = self.get_service("case")
success = await sealed_case_service.open_sealed_case(db, item.physical_item) success = await case_service.open_case(db, item.inventory_item.physical_item, 562119)
logger.info(f"sealed case {case_num} opening success: {success}") logger.info(f"sealed case {case_num} opening success: {success}")
case_num += 1 case_num += 1

View File

@@ -2,6 +2,11 @@ from typing import Dict, List, Optional
from app.services.external_api.tcgplayer.base_tcgplayer_service import BaseTCGPlayerService from app.services.external_api.tcgplayer.base_tcgplayer_service import BaseTCGPlayerService
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.schemas.file import FileInDB 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): class TCGPlayerInventoryService(BaseTCGPlayerService):
def __init__(self): def __init__(self):
@@ -24,10 +29,101 @@ class TCGPlayerInventoryService(BaseTCGPlayerService):
raise ValueError(f"Invalid export type: {export_type}, must be 'staged', 'live', or 'pricing'") 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) 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, db=db,
file_data=file_bytes, file_data=file_bytes,
file_name=f"tcgplayer_{export_type}_export.csv", filename=f"tcgplayer_{export_type}_export.csv",
subdir="tcgplayer/inventory", subdir="tcgplayer/inventory",
file_type=file_type 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)
async def refresh_unmanaged_tcgplayer_inventory_table(self, db: Session):
"""
Refresh the TCGPlayer unmanaged inventory table
unmanaged inventory is any inventory that cannot be mapped to a card with a marketplace listing
"""
with transaction(db):
# Get active marketplace listings with their physical items in a single query
listed_cards = (
db.query(MarketplaceListing)
.join(MarketplaceListing.inventory_item)
.join(InventoryItem.physical_item)
.filter(
func.lower(Marketplace.name) == func.lower("tcgplayer"),
MarketplaceListing.delisting_date == None,
MarketplaceListing.deleted_at == None,
MarketplaceListing.listing_date != None
)
.all()
)
# Get current inventory and create lookup dict
current_inventory = db.query(TCGPlayerInventory).all()
# Create a set of SKUs that have active listings
listed_skus = {
card.inventory_item.physical_item.tcgplayer_sku_id
for card in listed_cards
}
unmanaged_inventory = []
for inventory in current_inventory:
# Only include SKUs that have no active listings
if inventory.tcgplayer_sku_id not in listed_skus:
unmanaged_inventory.append({
"tcgplayer_inventory_id": inventory.id,
"tcgplayer_sku_id": inventory.tcgplayer_sku_id,
"product_line": inventory.product_line,
"set_name": inventory.set_name,
"product_name": inventory.product_name,
"title": inventory.title,
"number": inventory.number,
"rarity": inventory.rarity,
"condition": inventory.condition,
"tcg_market_price": inventory.tcg_market_price,
"tcg_direct_low": inventory.tcg_direct_low,
"tcg_low_price_with_shipping": inventory.tcg_low_price_with_shipping,
"tcg_low_price": inventory.tcg_low_price,
"total_quantity": inventory.total_quantity,
"add_to_quantity": inventory.add_to_quantity,
"tcg_marketplace_price": inventory.tcg_marketplace_price,
"photo_url": inventory.photo_url
})
db.bulk_insert_mappings(UnmanagedTCGPlayerInventory, unmanaged_inventory)

View File

@@ -120,7 +120,7 @@ class FileService:
"""List files with optional filtering""" """List files with optional filtering"""
query = db.query(File) query = db.query(File)
if file_type: if file_type:
query = query.filter(File.type == file_type).order_by(File.created_at.desc()) 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() files = query.offset(skip).limit(limit).all()
return [FileInDB.model_validate(file) for file in files] return [FileInDB.model_validate(file) for file in files]
@@ -158,3 +158,8 @@ class FileService:
if file_record: if file_record:
return FileInDB.model_validate(file_record) return FileInDB.model_validate(file_record)
return None 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()

View File

@@ -1,14 +1,16 @@
from typing import List, Optional, Dict, TypedDict from typing import List, Optional, Dict, TypedDict
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, joinedload
from decimal import Decimal
from app.services.base_service import BaseService from app.services.base_service import BaseService
from app.models.manabox_import_staging import ManaboxImportStaging from app.models.manabox_import_staging import ManaboxImportStaging
from app.contexts.inventory_item import InventoryItemContextFactory from app.contexts.inventory_item import InventoryItemContextFactory
from app.models.inventory_management import ( from app.models.inventory_management import (
SealedBox, OpenEvent, OpenBox, OpenCard, InventoryItem, SealedCase, OpenEvent, Card, InventoryItem, Case, SealedExpectedValue,
Transaction, TransactionItem, Customer, Vendor, Marketplace Transaction, TransactionItem, Customer, Vendor, Marketplace, Box, MarketplaceListing
) )
from app.schemas.file import FileInDB from app.schemas.file import FileInDB
from app.schemas.transaction import PurchaseTransactionCreate, SaleTransactionCreate, TransactionResponse 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 app.db.database import transaction as db_transaction
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@@ -20,91 +22,59 @@ class InventoryService(BaseService):
def __init__(self): def __init__(self):
super().__init__(None) super().__init__(None)
async def process_manabox_import_staging(self, db: Session, manabox_file_uploads: List[FileInDB], sealed_box: SealedBox) -> bool: async def get_resulting_items_for_open_event(self, db: Session, open_event: OpenEvent) -> List[InventoryItem]:
try: # 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()
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()
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): with db_transaction(db):
# Check if box is already opened expected_value = SealedExpectedValue(
existing_open_event = db.query(OpenEvent).filter( tcgplayer_product_id=expected_value_data.tcgplayer_product_id,
OpenEvent.sealed_box_id == sealed_box.id, expected_value=expected_value_data.expected_value
OpenEvent.deleted_at.is_(None)
).first()
if existing_open_event:
raise ValueError(f"Box {sealed_box.id} has already been opened")
# 1. Get the InventoryItemContext for the sealed box
inventory_item_context = InventoryItemContextFactory(db).get_context(sealed_box.inventory_item)
# 2. Create the OpenEvent
open_event = OpenEvent(
sealed_box_id=sealed_box.id,
open_date=datetime.now(),
created_at=datetime.now(),
updated_at=datetime.now()
) )
db.add(open_event) db.add(expected_value)
db.flush() # Get the ID for relationships db.flush()
return expected_value
# 3. Create the OpenBox from the SealedBox
open_box = OpenBox(
open_event_id=open_event.id,
product_id=sealed_box.product_id,
sealed_box_id=sealed_box.id,
created_at=datetime.now(),
updated_at=datetime.now()
)
db.add(open_box)
# 4. Process each card from the CSV
total_market_value = 0
cards = []
manabox_file_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_file_upload_ids)).all()
for record in staging_data:
for i in range(record.quantity):
# Create the OpenCard
open_card = OpenCard(
product_id=record.product_id,
open_event_id=open_event.id,
box_id=open_box.id,
created_at=datetime.now(),
updated_at=datetime.now()
)
db.add(open_card)
# Create the InventoryItem for the card
card_inventory_item = InventoryItem(
physical_item=open_card,
cost_basis=0, # Will be calculated later
created_at=datetime.now(),
updated_at=datetime.now()
)
db.add(card_inventory_item)
# Get the market value for cost basis distribution
card_context = InventoryItemContextFactory(db).get_context(card_inventory_item)
market_value = card_context.market_price
logger.debug(f"market_value: {market_value}")
total_market_value += market_value
cards.append((open_card, card_inventory_item, market_value))
# 5. Distribute the cost basis
original_cost_basis = inventory_item_context.cost_basis
for open_card, card_inventory_item, market_value in cards:
# Calculate this card's share of the cost basis
logger.debug(f"market_value: {market_value}, total_market_value: {total_market_value}, original_cost_basis: {original_cost_basis}")
cost_basis_share = (market_value / total_market_value) * original_cost_basis
card_inventory_item.cost_basis = cost_basis_share
return True
except Exception as e:
raise e
async def create_purchase_transaction( async def create_purchase_transaction(
self, self,
@@ -125,106 +95,46 @@ class InventoryService(BaseService):
vendor_id=transaction_data.vendor_id, vendor_id=transaction_data.vendor_id,
transaction_type='purchase', transaction_type='purchase',
transaction_date=transaction_data.transaction_date, transaction_date=transaction_data.transaction_date,
transaction_notes=transaction_data.transaction_notes, transaction_notes=transaction_data.transaction_notes
created_at=datetime.now(),
updated_at=datetime.now()
) )
db.add(transaction) db.add(transaction)
db.flush() db.flush()
total_amount = 0 total_amount = 0
physical_items = [] physical_items = []
case_service = self.get_service("case")
box_service = self.get_service("box")
for item in transaction_data.items: for item in transaction_data.items:
# Create the physical item based on type # Create the physical item based on type
# TODO: remove is_case and num_boxes, should derive from product_id # TODO: remove is_case and num_boxes, should derive from product_id
# TODO: add support for purchasing single cards # TODO: add support for purchasing single cards
if item.is_case: if item.item_type == "case":
for i in range(item.quantity): for i in range(item.quantity):
physical_item = await SealedCaseService().create_sealed_case( physical_item = await case_service.create_case(
db=db, db=db,
product_id=item.product_id, product_id=item.product_id,
cost_basis=item.unit_price, cost_basis=item.unit_price,
num_boxes=item.num_boxes or 1 num_boxes=item.num_boxes
) )
physical_items.append(physical_item) physical_items.append(physical_item)
else: elif item.item_type == "box":
for i in range(item.quantity): for i in range(item.quantity):
physical_item = await SealedBoxService().create_sealed_box( physical_item = await box_service.create_box(
db=db, db=db,
product_id=item.product_id, product_id=item.product_id,
cost_basis=item.unit_price cost_basis=item.unit_price
) )
physical_items.append(physical_item) 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: for physical_item in physical_items:
# Create transaction item # Create transaction item
transaction_item = TransactionItem( transaction.transaction_items.append(TransactionItem(
transaction_id=transaction.id, inventory_item_id=physical_item.inventory_item.id,
physical_item_id=physical_item.id, unit_price=item.unit_price
unit_price=item.unit_price, ))
created_at=datetime.now(),
updated_at=datetime.now()
)
db.add(transaction_item)
total_amount += item.unit_price
# Update transaction total
transaction.transaction_total_amount = total_amount
return transaction
except Exception as e:
raise e
async def create_sale_transaction(
self,
db: Session,
transaction_data: SaleTransactionCreate
) -> Transaction:
"""
this is basically psuedocode not implemented yet
"""
try:
with db_transaction(db):
# Create the transaction
transaction = Transaction(
customer_id=transaction_data.customer_id,
marketplace_id=transaction_data.marketplace_id,
transaction_type='sale',
transaction_date=transaction_data.transaction_date,
transaction_notes=transaction_data.transaction_notes,
created_at=datetime.now(),
updated_at=datetime.now()
)
db.add(transaction)
db.flush()
total_amount = 0
for item in transaction_data.items:
# Get the inventory item and validate
inventory_item = db.query(InventoryItem).filter(
InventoryItem.id == item.inventory_item_id,
InventoryItem.deleted_at.is_(None)
).first()
if not inventory_item:
raise ValueError(f"Inventory item {item.inventory_item_id} not found")
# Create transaction item
transaction_item = TransactionItem(
transaction_id=transaction.id,
physical_item_id=inventory_item.physical_item_id,
unit_price=item.unit_price,
created_at=datetime.now(),
updated_at=datetime.now()
)
db.add(transaction_item)
total_amount += item.unit_price
# Update marketplace listing if applicable
if transaction_data.marketplace_id and inventory_item.marketplace_listings:
listing = inventory_item.marketplace_listings
listing.delisting_date = transaction_data.transaction_date
listing.updated_at = datetime.now()
# Update transaction total # Update transaction total
transaction.transaction_total_amount = total_amount transaction.transaction_total_amount = total_amount
@@ -246,9 +156,7 @@ class InventoryService(BaseService):
with db_transaction(db): with db_transaction(db):
customer = Customer( customer = Customer(
name=customer_name, name=customer_name
created_at=datetime.now(),
updated_at=datetime.now()
) )
db.add(customer) db.add(customer)
db.flush() db.flush()
@@ -270,9 +178,7 @@ class InventoryService(BaseService):
with db_transaction(db): with db_transaction(db):
vendor = Vendor( vendor = Vendor(
name=vendor_name, name=vendor_name
created_at=datetime.now(),
updated_at=datetime.now()
) )
db.add(vendor) db.add(vendor)
db.flush() db.flush()
@@ -281,6 +187,12 @@ class InventoryService(BaseService):
except Exception as e: except Exception as e:
raise e raise e
async def get_vendors(
self,
db: Session
) -> List[Vendor]:
return db.query(Vendor).all()
async def create_marketplace( async def create_marketplace(
self, self,
db: Session, db: Session,
@@ -294,9 +206,7 @@ class InventoryService(BaseService):
with db_transaction(db): with db_transaction(db):
marketplace = Marketplace( marketplace = Marketplace(
name=marketplace_name, name=marketplace_name
created_at=datetime.now(),
updated_at=datetime.now()
) )
db.add(marketplace) db.add(marketplace)
db.flush() db.flush()
@@ -305,110 +215,150 @@ class InventoryService(BaseService):
except Exception as e: except Exception as e:
raise e raise e
class SealedBoxService(BaseService[SealedBox]): async def get_marketplaces(
def __init__(self): self,
super().__init__(SealedBox) db: Session
) -> List[Marketplace]:
return db.query(Marketplace).all()
async def create_sealed_box( class BoxService(BaseService[Box]):
def __init__(self):
super().__init__(Box)
async def create_box(
self, self,
db: Session, db: Session,
product_id: int, product_id: int,
cost_basis: float, cost_basis: float
case_id: Optional[int] = None ) -> Box:
) -> SealedBox:
try: try:
with db_transaction(db): with db_transaction(db):
# Create the SealedBox # Create the SealedBox
sealed_box = SealedBox( box = Box(
product_id=product_id, tcgplayer_product_id=product_id
created_at=datetime.now(),
updated_at=datetime.now()
) )
db.add(sealed_box) db.add(box)
db.flush() # Get the ID for relationships db.flush() # Get the ID for relationships
# If this box is part of a case, link it expected_value = box.products.sealed_expected_value.expected_value
if case_id: box.expected_value = expected_value
case = db.query(SealedCase).filter(SealedCase.id == case_id).first() db.flush()
if not case:
raise ValueError(f"Case {case_id} not found")
sealed_box.case_id = case_id
# Create the InventoryItem for the sealed box # Create the InventoryItem for the sealed box
inventory_item = InventoryItem( inventory_item = InventoryItem(
physical_item=sealed_box, physical_item=box,
cost_basis=cost_basis, cost_basis=cost_basis
created_at=datetime.now(),
updated_at=datetime.now()
) )
db.add(inventory_item) db.add(inventory_item)
return sealed_box return box
except Exception as e: except Exception as e:
raise e raise e
class SealedCaseService(BaseService[SealedCase]): async def calculate_cost_basis_for_opened_cards(self, db: Session, open_event: OpenEvent) -> float:
def __init__(self): box_cost_basis = open_event.source_item.inventory_item.cost_basis
super().__init__(SealedCase) 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 create_sealed_case(self, db: Session, product_id: int, cost_basis: float, num_boxes: int) -> SealedCase: 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()
# 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: try:
with db_transaction(db): with db_transaction(db):
# Create the SealedCase # Create the SealedCase
sealed_case = SealedCase( case = Case(
product_id=product_id, tcgplayer_product_id=product_id,
num_boxes=num_boxes, num_boxes=num_boxes
created_at=datetime.now(),
updated_at=datetime.now()
) )
db.add(sealed_case) db.add(case)
db.flush() # Get the ID for relationships db.flush() # Get the ID for relationships
case.expected_value = case.products.sealed_expected_value.expected_value
# Create the InventoryItem for the sealed case # Create the InventoryItem for the sealed case
inventory_item = InventoryItem( inventory_item = InventoryItem(
physical_item=sealed_case, physical_item=case,
cost_basis=cost_basis, cost_basis=cost_basis
created_at=datetime.now(),
updated_at=datetime.now()
) )
db.add(inventory_item) db.add(inventory_item)
return sealed_case return case
except Exception as e: except Exception as e:
raise e raise e
async def open_sealed_case(self, db: Session, sealed_case: SealedCase) -> bool: async def open_case(self, db: Session, case: Case, child_product_id: int) -> bool:
try: try:
sealed_case_context = InventoryItemContextFactory(db).get_context(sealed_case.inventory_item) ## 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): with db_transaction(db):
# Create the OpenEvent # Create the OpenEvent
open_event = OpenEvent( open_event = OpenEvent(
sealed_case_id=sealed_case_context.physical_item.id, source_item=case,
open_date=datetime.now(), open_date=datetime.now()
created_at=datetime.now(),
updated_at=datetime.now()
) )
db.add(open_event) db.add(open_event)
db.flush() # Get the ID for relationships db.flush() # Get the ID for relationships
# Create num_boxes SealedBoxes # Create num_boxes SealedBoxes
for i in range(sealed_case.num_boxes): for i in range(case.num_boxes):
sealed_box = SealedBox( new_box = Box(
product_id=sealed_case_context.physical_item.product_id, tcgplayer_product_id=child_product_id
created_at=datetime.now(),
updated_at=datetime.now()
) )
db.add(sealed_box) open_event.resulting_items.append(new_box)
db.flush() # Get the ID for relationships db.flush()
per_box_cost_basis = case.inventory_item.cost_basis / case.num_boxes
# Create the InventoryItem for the sealed box # Create the InventoryItem for the sealed box
inventory_item = InventoryItem( inventory_item = InventoryItem(
physical_item=sealed_box, physical_item=new_box,
cost_basis=sealed_case_context.cost_basis, cost_basis=per_box_cost_basis
created_at=datetime.now(),
updated_at=datetime.now()
) )
db.add(inventory_item) db.add(inventory_item)
@@ -416,3 +366,140 @@ class SealedCaseService(BaseService[SealedCase]):
except Exception as e: except Exception as e:
raise 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(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:
try:
with db_transaction(db):
marketplace_listing.listed_price = self.pricing_service.set_price(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

View File

@@ -9,7 +9,7 @@ from typing import Dict, Any, Union, List
import csv import csv
import logging import logging
from datetime import datetime from datetime import datetime
import asyncio from fastapi import BackgroundTasks
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -17,7 +17,7 @@ class ManaboxService(BaseService):
def __init__(self): def __init__(self):
super().__init__(None) super().__init__(None)
async def process_manabox_csv(self, db: Session, bytes: bytes, metadata: Dict[str, Any], wait: bool = False) -> Union[bool, List[FileInDB]]: 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 # save file
file = await self.file_service.save_file( file = await self.file_service.save_file(
db=db, db=db,
@@ -29,15 +29,12 @@ class ManaboxService(BaseService):
metadata=metadata metadata=metadata
) )
# Create the background task
task = asyncio.create_task(self._process_file_background(db, file))
# If wait is True, wait for the task to complete and return the file
if wait: if wait:
await task await self._process_file_background(db, file)
return_value = await self.file_service.get_file(db, file.id) return_value = await self.file_service.get_file(db, file.id)
return [return_value] if return_value else [] return [return_value] if return_value else []
else:
background_tasks.add_task(self._process_file_background, db, file)
return True return True
async def _process_file_background(self, db: Session, file: FileInDB): async def _process_file_background(self, db: Session, file: FileInDB):
@@ -45,68 +42,98 @@ class ManaboxService(BaseService):
# Read the CSV file # Read the CSV file
with open(file.path, 'r') as csv_file: with open(file.path, 'r') as csv_file:
reader = csv.DictReader(csv_file) reader = csv.DictReader(csv_file)
# skip header row logger.debug(f"Processing file: {file.path}")
next(reader) # 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: for row in reader:
# match scryfall id to mtgjson scryfall id, make sure only one distinct tcgplayer id
mtg_json = db.query(MTGJSONCard).filter(MTGJSONCard.scryfall_id == row['Scryfall ID']).all()
# count distinct tcgplayer ids
cd_tcgplayer_ids = db.query(MTGJSONCard.tcgplayer_sku_id).filter(MTGJSONCard.scryfall_id == row['Scryfall ID']).distinct().count()
if cd_tcgplayer_ids != 1:
logger.error(f"Error: multiple TCGplayer IDs found for scryfall id: {row['Scryfall ID']} found {cd_tcgplayer_ids} ids expected 1")
with transaction(db):
critical_error_log = CriticalErrorLog(
error_message=f"Error: multiple TCGplayer IDs found for scryfall id: {row['Scryfall ID']} found {cd_tcgplayer_ids} ids expected 1"
)
db.add(critical_error_log)
continue
else:
mtg_json = mtg_json[0]
# get tcgplayer sku id from mtgjson skus
language = 'ENGLISH' if row['Language'] == 'en' else 'JAPANESE' if row['Language'] == 'ja' else None
if row['Foil'].lower() == 'etched':
printing = 'FOIL'
tcgplayer_sku = db.query(MTGJSONSKU).filter(MTGJSONSKU.tcgplayer_sku_id == mtg_json.tcgplayer_etched_sku_id).filter(MTGJSONSKU.condition == row['Condition'].replace('_', ' ').upper()).filter(MTGJSONSKU.printing == printing).filter(MTGJSONSKU.language == language).distinct().all()
else:
printing = 'FOIL' if row['Foil'].lower() == 'foil' else 'NON FOIL'
tcgplayer_sku = db.query(MTGJSONSKU).filter(MTGJSONSKU.tcgplayer_sku_id == mtg_json.tcgplayer_sku_id).filter(MTGJSONSKU.condition == row['Condition'].replace('_', ' ').upper()).filter(MTGJSONSKU.printing == printing).filter(MTGJSONSKU.language == language).distinct().all()
# count distinct tcgplayer skus
if len(tcgplayer_sku) == 0:
logger.error(f"Error: No TCGplayer SKU found for mtgjson name: {mtg_json.name} condition: {row['Condition']} language: {language} printing: {printing}")
with transaction(db):
critical_error_log = CriticalErrorLog(
error_message=f"Error: No TCGplayer SKU found for mtgjson name: {mtg_json.name} condition: {row['Condition']} language: {language} printing: {printing}"
)
db.add(critical_error_log)
continue
elif len(tcgplayer_sku) > 1:
logger.error(f"Error: {len(tcgplayer_sku)} TCGplayer SKUs found for mtgjson name: {mtg_json.name} condition: {row['Condition']} language: {language} printing: {printing}")
with transaction(db):
critical_error_log = CriticalErrorLog(
error_message=f"Error: {len(tcgplayer_sku)} TCGplayer SKUs found for mtgjson name: {mtg_json.name} condition: {row['Condition']} language: {language} printing: {printing}"
)
db.add(critical_error_log)
continue
else:
tcgplayer_sku = tcgplayer_sku[0]
# look up tcgplayer product data for sku
tcgplayer_product = db.query(TCGPlayerProduct).filter(TCGPlayerProduct.tcgplayer_product_id == tcgplayer_sku.tcgplayer_product_id).filter(TCGPlayerProduct.condition == row['Condition'].replace('_', ' ').upper()).filter(TCGPlayerProduct.language == language).filter(TCGPlayerProduct.printing == printing).first()
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']) 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}")
with transaction(db): staging_entries.append(ManaboxImportStaging(
manabox_import_staging = ManaboxImportStaging(
file_id=file.id, file_id=file.id,
product_id=tcgplayer_product.id, tcgplayer_product_id=tcgplayer_product.tcgplayer_product_id,
quantity=quantity, tcgplayer_sku_id=tcgplayer_sku.tcgplayer_sku_id,
created_at=datetime.now(), quantity=quantity
updated_at=datetime.now() ))
)
db.add(manabox_import_staging) # 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: except Exception as e:
logger.error(f"Error processing file: {str(e)}") logger.error(f"Error processing file: {str(e)}")
with transaction(db): with transaction(db):
critical_error_log = CriticalErrorLog( critical_error_log = CriticalErrorLog(error_message=f"Error processing file: {str(e)}")
error_message=f"Error processing file: {str(e)}"
)
db.add(critical_error_log) db.add(critical_error_log)

View File

@@ -0,0 +1,182 @@
import logging
from sqlalchemy.orm import Session
from app.services.base_service import BaseService
from app.models.inventory_management import InventoryItem, MarketplaceListing
from app.models.tcgplayer_inventory import TCGPlayerInventory
from app.models.pricing import PricingEvent
from app.db.database import transaction
from decimal import Decimal
from datetime import datetime
logger = logging.getLogger(__name__)
class PricingService(BaseService):
def __init__(self):
super().__init__(None)
async def set_price(self, db: Session, inventory_item: InventoryItem) -> float:
"""
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 = Decimal(str(inventory_item.cost_basis))
market_price = Decimal(str(inventory_item.physical_item.sku.product.most_recent_tcgplayer_price.market_price))
tcg_low = Decimal(str(inventory_item.physical_item.sku.product.most_recent_tcgplayer_price.low_price))
tcg_mid = Decimal(str(inventory_item.physical_item.sku.product.most_recent_tcgplayer_price.mid_price))
listed_price = Decimal(str(inventory_item.marketplace_listing.listed_price)) if inventory_item.marketplace_listing else None
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.20')
velocity_multiplier = Decimal('0.0')
global_margin_multiplier = Decimal('0.00')
min_floor_price = Decimal('0.25')
price_drop_threshold = Decimal('0.20')
# 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')
# Fetch current total quantity in stock for SKU
quantity_record = db.query(TCGPlayerInventory).filter(
TCGPlayerInventory.tcgplayer_sku_id == inventory_item.physical_item.tcgplayer_sku_id
).first()
quantity_in_stock = quantity_record.total_quantity if quantity_record else 0
# 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'))
# 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
)
# Enforce minimum floor price to ensure profitability
if adjusted_price < min_floor_price:
adjusted_price = min_floor_price
# 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 < (market_price * Decimal('0.8')) and adjusted_price < (tcg_mid * Decimal('0.8')):
adjusted_price = tcg_mid
price_used = "tcg mid"
price_reason = f"adjusted price below 80% of market price and tcg mid"
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"
# Record pricing event in database transaction
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 and inventory_item.marketplace_listing.listed_price:
inventory_item.marketplace_listing.listed_price.deleted_at = datetime.now()
return pricing_event
def set_price_for_unmanaged_inventory(self, db: Session, tcgplayer_sku_id: int, quantity: int) -> float:
pass
def update_price_for_product(self, db: Session, tcgplayer_sku_id: int) -> list[PricingEvent]:
# get inventory items for sku
updated_prices = []
inventory_items = db.query(InventoryItem).filter(
InventoryItem.physical_item.tcgplayer_sku_id == tcgplayer_sku_id
).all()
for inventory_item in inventory_items:
pricing_event = self.set_price(db, inventory_item)
updated_prices.append(pricing_event)
return updated_prices
def set_price_for_product(self, db: Session, tcgplayer_sku_id: int) -> float:
# update price for all inventory items for sku
prices = self.update_price_for_product(db, tcgplayer_sku_id)
sum_prices = sum(price.price for price in prices)
average_price = sum_prices / len(prices)
return average_price

View File

@@ -113,7 +113,7 @@ class PullSheetService(BaseService):
'quantity': str(int(row['Quantity'])), # Convert to string for template 'quantity': str(int(row['Quantity'])), # Convert to string for template
'set': row['Set'], 'set': row['Set'],
'rarity': row['Rarity'], '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 ''
}) })
return items return items

View File

@@ -22,16 +22,6 @@ class BaseScheduler:
*args, *args,
**kwargs **kwargs
) -> None: ) -> None:
"""Schedule a task to run at regular intervals or at specific times using APScheduler
Args:
task_name: Name of the task
func: Function to execute
interval_seconds: Interval in seconds for periodic execution (mutually exclusive with cron_expression)
cron_expression: Cron expression for time-based scheduling (mutually exclusive with interval_seconds)
*args: Additional positional arguments for the function
**kwargs: Additional keyword arguments for the function
"""
if task_name in self.jobs: if task_name in self.jobs:
logger.warning(f"Task {task_name} already exists. Removing existing job.") logger.warning(f"Task {task_name} already exists. Removing existing job.")
self.jobs[task_name].remove() self.jobs[task_name].remove()
@@ -47,7 +37,7 @@ class BaseScheduler:
trigger = CronTrigger.from_crontab(cron_expression) trigger = CronTrigger.from_crontab(cron_expression)
job = self.scheduler.add_job( job = self.scheduler.add_job(
func, func=func,
trigger=trigger, trigger=trigger,
args=args, args=args,
kwargs=kwargs, kwargs=kwargs,
@@ -56,11 +46,13 @@ class BaseScheduler:
) )
self.jobs[task_name] = job self.jobs[task_name] = job
if interval_seconds: if interval_seconds:
logger.info(f"Scheduled task {task_name} to run every {interval_seconds} seconds") logger.info(f"Scheduled task {task_name} to run every {interval_seconds} seconds")
else: else:
logger.info(f"Scheduled task {task_name} with cron expression: {cron_expression}") logger.info(f"Scheduled task {task_name} with cron expression: {cron_expression}")
def remove_task(self, task_name: str) -> None: def remove_task(self, task_name: str) -> None:
"""Remove a scheduled task""" """Remove a scheduled task"""
if task_name in self.jobs: if task_name in self.jobs:

View File

@@ -3,7 +3,7 @@ from app.services.scheduler.base_scheduler import BaseScheduler
from app.services.base_service import BaseService from app.services.base_service import BaseService
from sqlalchemy import text from sqlalchemy import text
import logging import logging
from app.models.tcgplayer_inventory import UnmanagedTCGPlayerInventory, TCGPlayerInventory
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class SchedulerService(BaseService): class SchedulerService(BaseService):
@@ -12,15 +12,6 @@ class SchedulerService(BaseService):
super().__init__(None) super().__init__(None)
self.scheduler = BaseScheduler() self.scheduler = BaseScheduler()
async def update_tcgplayer_price_history_daily(self, db):
"""
Update the TCGPlayer price history table
"""
with transaction(db):
await db.execute(text("""REFRESH MATERIALIZED VIEW CONCURRENTLY most_recent_tcgplayer_price;"""))
logger.info("TCGPlayer price history refreshed")
async def update_open_orders_hourly(self, db): async def update_open_orders_hourly(self, db):
""" """
Hourly update of orders from TCGPlayer API to database Hourly update of orders from TCGPlayer API to database
@@ -65,25 +56,41 @@ class SchedulerService(BaseService):
logger.error(f"Error updating all orders: {str(e)}") logger.error(f"Error updating all orders: {str(e)}")
raise raise
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()
await tcgplayer_inventory_service.refresh_unmanaged_tcgplayer_inventory_table(db)
async def start_scheduled_tasks(self, db): async def start_scheduled_tasks(self, db):
"""Start all scheduled tasks""" """Start all scheduled tasks"""
# Schedule open orders update to run hourly at 00 minutes # Schedule open orders update to run hourly at 00 minutes
await self.scheduler.schedule_task( await self.scheduler.schedule_task(
task_name="update_open_orders_hourly", task_name="update_open_orders_hourly",
func=lambda: self.update_open_orders_hourly(db), func=self.update_open_orders_hourly,
cron_expression="0 * * * *" # Run at minute 0 of every hour cron_expression="10 * * * *", # Run at minute 10 of every hour
db=db
) )
# Schedule all orders update to run daily at 3 AM # Schedule all orders update to run daily at 3 AM
await self.scheduler.schedule_task( await self.scheduler.schedule_task(
task_name="update_all_orders_daily", task_name="update_all_orders_daily",
func=lambda: self.update_all_orders_daily(db), func=self.update_all_orders_daily,
cron_expression="0 3 * * *" # Run at 3:00 AM every day cron_expression="0 3 * * *", # Run at 3:00 AM every day
db=db
) )
# Schedule TCGPlayer price history update to run daily at 1 AM # Schedule TCGPlayer inventory refresh to run every 3 hours
await self.scheduler.schedule_task( await self.scheduler.schedule_task(
task_name="update_tcgplayer_price_history_daily", task_name="refresh_tcgplayer_inventory_table",
func=lambda: self.update_tcgplayer_price_history_daily(db), func=self.refresh_tcgplayer_inventory_table,
cron_expression="0 1 * * *" # Run at 1:00 AM every day cron_expression="28 */3 * * *", # Run at minute 28 of every 3rd hour
db=db
) )
self.scheduler.start() self.scheduler.start()

View File

@@ -30,9 +30,11 @@ class ServiceManager:
'tcgcsv': 'app.services.external_api.tcgcsv.tcgcsv_service.TCGCSVService', 'tcgcsv': 'app.services.external_api.tcgcsv.tcgcsv_service.TCGCSVService',
'mtgjson': 'app.services.external_api.mtgjson.mtgjson_service.MTGJSONService', 'mtgjson': 'app.services.external_api.mtgjson.mtgjson_service.MTGJSONService',
'manabox': 'app.services.manabox_service.ManaboxService', 'manabox': 'app.services.manabox_service.ManaboxService',
'pricing': 'app.services.pricing_service.PricingService',
'inventory': 'app.services.inventory_service.InventoryService', 'inventory': 'app.services.inventory_service.InventoryService',
'sealed_box': 'app.services.inventory_service.SealedBoxService', 'box': 'app.services.inventory_service.BoxService',
'sealed_case': 'app.services.inventory_service.SealedCaseService' 'case': 'app.services.inventory_service.CaseService',
'marketplace_listing': 'app.services.inventory_service.MarketplaceListingService'
} }
self._service_configs = { self._service_configs = {

View File

@@ -10,7 +10,7 @@ import aiohttp
import jinja2 import jinja2
from weasyprint import HTML from weasyprint import HTML
from app.services.base_service import BaseService from app.services.base_service import BaseService
from app.models.tcgplayer_products import TCGPlayerProduct from app.models.tcgplayer_products import TCGPlayerProduct, TCGPlayerGroup
log = logging.getLogger(__name__) log = logging.getLogger(__name__)

View File

@@ -20,6 +20,22 @@
</script> </script>
</head> </head>
<body class="bg-gray-900 min-h-screen text-gray-100"> <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="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"> <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> <h1 class="text-3xl font-bold text-gray-100 mb-2">TCGPlayer Order Management</h1>

86
app/static/manabox.html Normal file
View 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
View 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);
}
});
});
});

View File

@@ -106,3 +106,125 @@ button:hover {
width: 100%; 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;
}

View File

@@ -0,0 +1,179 @@
<!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">
<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>
</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>

1077
app/static/transactions.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,88 @@
aiofiles==24.1.0
aiohappyeyeballs==2.6.1 aiohappyeyeballs==2.6.1
aiohttp==3.11.16 aiohttp==3.11.16
aiojson==0.3.1612466996
aiosignal==1.3.2 aiosignal==1.3.2
alembic==1.13.1 alembic==1.15.2
annotated-types==0.7.0 annotated-types==0.7.0
anyio==4.9.0 anyio==4.9.0
APScheduler==3.10.4 APScheduler==3.11.0
attrs==25.3.0 attrs==25.3.0
blinker==1.9.0 blinker==1.9.0
brother_ql_next==0.11.3
Brotli==1.1.0
cairocffi==1.7.1
certifi==2025.1.31
cffi==1.17.1
charset-normalizer==3.4.1
click==8.1.8 click==8.1.8
cssselect2==0.8.0
defusedxml==0.7.1
dotenv==0.9.9
fastapi==0.115.12 fastapi==0.115.12
Flask==3.1.0 Flask==3.1.0
fonttools==4.57.0
frozenlist==1.5.0 frozenlist==1.5.0
future==1.0.0
greenlet==3.1.1
h11==0.14.0 h11==0.14.0
httpcore==1.0.8
httpx==0.28.1
idna==3.10 idna==3.10
ijson==3.3.0
inflate64==1.0.1
iniconfig==2.1.0
itsdangerous==2.2.0 itsdangerous==2.2.0
Jinja2==3.1.6 Jinja2==3.1.6
jsons==1.6.3
Mako==1.3.9 Mako==1.3.9
MarkupSafe==3.0.2 MarkupSafe==3.0.2
multidict==6.4.2 multidict==6.2.0
multivolumefile==0.2.3
numpy==2.2.4
packaging==24.2
packbits==0.6
pandas==2.2.3
pdf2image==1.17.0
pillow==11.2.1
pluggy==1.5.0
propcache==0.3.1 propcache==0.3.1
psutil==7.0.0
psycopg2-binary==2.9.10
py7zr==0.22.0
pybcj==1.0.3
pycparser==2.22
pycryptodomex==3.22.0
pycups==2.0.4
pydantic==2.11.3 pydantic==2.11.3
pydantic_core==2.33.1 pydantic_core==2.33.1
pydyf==0.11.0
pyphen==0.17.2
pyppmd==1.1.1
pytest==8.3.5
python-dateutil==2.9.0.post0
python-dotenv==1.1.0
python-multipart==0.0.20
pytz==2025.2 pytz==2025.2
pyusb==1.3.1
pyzstd==0.16.2
requests==2.32.3
six==1.17.0 six==1.17.0
sniffio==1.3.1 sniffio==1.3.1
SQLAlchemy==2.0.40 SQLAlchemy==2.0.40
starlette==0.46.1 starlette==0.46.1
texttable==1.7.0
tinycss2==1.4.0
tinyhtml5==2.0.0
typing-inspection==0.4.0 typing-inspection==0.4.0
typing_extensions==4.13.1 typing_extensions==4.13.1
typish==1.9.3
tzdata==2025.2
tzlocal==5.3.1 tzlocal==5.3.1
urllib3==2.4.0
uvicorn==0.34.0 uvicorn==0.34.0
weasyprint==65.0
webencodings==0.5.1
Werkzeug==3.1.3 Werkzeug==3.1.3
yarl==1.19.0 yarl==1.19.0
py7zr>=0.20.8 zopfli==0.2.3.post1
psycopg2-binary==2.9.9
python-dotenv==1.0.1