so much stuff lol

This commit is contained in:
zman 2025-04-09 23:53:05 -04:00
parent 1c00ea8569
commit df6490cab0
40 changed files with 1909 additions and 277 deletions

2
.gitignore vendored
View File

@ -8,3 +8,5 @@
__pycache__
_cookie.py
app/data/cache/*

114
alembic.ini Normal file
View File

@ -0,0 +1,114 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or colons.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = sqlite:///database.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
alembic/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration.

84
alembic/env.py Normal file
View File

@ -0,0 +1,84 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# Import your models here
from app.db.database import Base
from app.models.inventory import Inventory
from app.models.card import Card
from app.models.box import Box, OpenBox
from app.models.game import Game
from app.models.file import File
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

26
alembic/script.py.mako Normal file
View File

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,49 @@
"""create tcgplayer categories table
Revision ID: 2025_04_09_create_tcgplayer_categories_table
Revises: remove_product_id_unique_constraint
Create Date: 2025-04-09 23:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '2025_04_09_create_tcgplayer_categories_table'
down_revision: str = 'remove_product_id_unique_constraint'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table('tcgplayer_categories',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('category_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('display_name', sa.String(), nullable=True),
sa.Column('seo_category_name', sa.String(), nullable=True),
sa.Column('category_description', sa.String(), nullable=True),
sa.Column('category_page_title', sa.String(), nullable=True),
sa.Column('sealed_label', sa.String(), nullable=True),
sa.Column('non_sealed_label', sa.String(), nullable=True),
sa.Column('condition_guide_url', sa.String(), nullable=True),
sa.Column('is_scannable', sa.Boolean(), nullable=True, default=False),
sa.Column('popularity', sa.Integer(), nullable=True, default=0),
sa.Column('is_direct', sa.Boolean(), nullable=True, default=False),
sa.Column('modified_on', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('category_id')
)
op.create_index('ix_tcgplayer_categories_id', 'tcgplayer_categories', ['id'], unique=False)
op.create_index('ix_tcgplayer_categories_category_id', 'tcgplayer_categories', ['category_id'], unique=True)
def downgrade() -> None:
op.drop_index('ix_tcgplayer_categories_category_id', table_name='tcgplayer_categories')
op.drop_index('ix_tcgplayer_categories_id', table_name='tcgplayer_categories')
op.drop_table('tcgplayer_categories')

View File

@ -0,0 +1,171 @@
"""create inventory table
Revision ID: 4dbeb89dd33a
Revises:
Create Date: 2025-04-09 21:56:49.068087
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4dbeb89dd33a'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('boxes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=True),
sa.Column('type', sa.String(), nullable=True),
sa.Column('set_code', sa.String(), nullable=True),
sa.Column('sku', sa.Integer(), nullable=True),
sa.Column('name', sa.String(), nullable=True),
sa.Column('expected_number_of_cards', sa.Integer(), nullable=True),
sa.Column('description', sa.String(), nullable=True),
sa.Column('image_url', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_boxes_id'), 'boxes', ['id'], unique=False)
op.create_table('cards',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('rarity', sa.String(), nullable=True),
sa.Column('set_name', sa.String(), nullable=True),
sa.Column('price', sa.Float(), nullable=True),
sa.Column('quantity', sa.Integer(), nullable=True),
sa.Column('tcgplayer_sku', sa.String(), nullable=True),
sa.Column('product_line', sa.String(), nullable=True),
sa.Column('product_name', sa.String(), nullable=True),
sa.Column('title', sa.String(), nullable=True),
sa.Column('number', sa.String(), nullable=True),
sa.Column('condition', sa.String(), nullable=True),
sa.Column('tcg_market_price', sa.Float(), nullable=True),
sa.Column('tcg_direct_low', sa.Float(), nullable=True),
sa.Column('tcg_low_price_with_shipping', sa.Float(), nullable=True),
sa.Column('tcg_low_price', sa.Float(), nullable=True),
sa.Column('total_quantity', sa.Integer(), nullable=True),
sa.Column('add_to_quantity', sa.Integer(), nullable=True),
sa.Column('tcg_marketplace_price', sa.Float(), nullable=True),
sa.Column('photo_url', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_cards_id'), 'cards', ['id'], unique=False)
op.create_index(op.f('ix_cards_name'), 'cards', ['name'], unique=False)
op.create_index(op.f('ix_cards_set_name'), 'cards', ['set_name'], unique=False)
op.create_index(op.f('ix_cards_tcgplayer_sku'), 'cards', ['tcgplayer_sku'], unique=True)
op.create_table('files',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('type', sa.String(), nullable=True),
sa.Column('path', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_files_id'), 'files', ['id'], unique=False)
op.create_table('games',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('description', sa.String(), nullable=True),
sa.Column('image_url', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_games_id'), 'games', ['id'], unique=False)
op.create_table('inventory',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tcgplayer_id', sa.String(), nullable=True),
sa.Column('product_line', sa.String(), nullable=True),
sa.Column('set_name', sa.String(), nullable=True),
sa.Column('product_name', sa.String(), nullable=True),
sa.Column('title', sa.String(), nullable=True),
sa.Column('number', sa.String(), nullable=True),
sa.Column('rarity', sa.String(), nullable=True),
sa.Column('condition', sa.String(), nullable=True),
sa.Column('tcg_market_price', sa.Float(), nullable=True),
sa.Column('tcg_direct_low', sa.Float(), nullable=True),
sa.Column('tcg_low_price_with_shipping', sa.Float(), nullable=True),
sa.Column('tcg_low_price', sa.Float(), nullable=True),
sa.Column('total_quantity', sa.Integer(), nullable=True),
sa.Column('add_to_quantity', sa.Integer(), nullable=True),
sa.Column('tcg_marketplace_price', sa.Float(), nullable=True),
sa.Column('photo_url', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_inventory_id'), 'inventory', ['id'], unique=False)
op.create_index(op.f('ix_inventory_tcgplayer_id'), 'inventory', ['tcgplayer_id'], unique=True)
op.create_table('open_boxes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('box_id', sa.Integer(), nullable=True),
sa.Column('number_of_cards', sa.Integer(), nullable=True),
sa.Column('date_opened', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['box_id'], ['boxes.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_open_boxes_id'), 'open_boxes', ['id'], unique=False)
op.create_table('tcgplayer_products',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('clean_name', sa.String(), nullable=True),
sa.Column('image_url', sa.String(), nullable=True),
sa.Column('category_id', sa.Integer(), nullable=True),
sa.Column('group_id', sa.Integer(), nullable=True),
sa.Column('url', sa.String(), nullable=True),
sa.Column('modified_on', sa.DateTime(), nullable=True),
sa.Column('image_count', sa.Integer(), nullable=True),
sa.Column('ext_rarity', sa.String(), nullable=True),
sa.Column('ext_number', sa.String(), nullable=True),
sa.Column('low_price', sa.Float(), nullable=True),
sa.Column('mid_price', sa.Float(), nullable=True),
sa.Column('high_price', sa.Float(), nullable=True),
sa.Column('market_price', sa.Float(), nullable=True),
sa.Column('direct_low_price', sa.Float(), nullable=True),
sa.Column('sub_type_name', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('product_id')
)
op.create_index(op.f('ix_tcgplayer_products_id'), 'tcgplayer_products', ['id'], unique=False)
op.create_index(op.f('ix_tcgplayer_products_product_id'), 'tcgplayer_products', ['product_id'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_tcgplayer_products_product_id'), table_name='tcgplayer_products')
op.drop_index(op.f('ix_tcgplayer_products_id'), table_name='tcgplayer_products')
op.drop_index(op.f('ix_open_boxes_id'), table_name='open_boxes')
op.drop_table('open_boxes')
op.drop_index(op.f('ix_inventory_tcgplayer_id'), table_name='inventory')
op.drop_index(op.f('ix_inventory_id'), table_name='inventory')
op.drop_table('inventory')
op.drop_index(op.f('ix_games_id'), table_name='games')
op.drop_table('games')
op.drop_index(op.f('ix_files_id'), table_name='files')
op.drop_table('files')
op.drop_index(op.f('ix_cards_tcgplayer_sku'), table_name='cards')
op.drop_index(op.f('ix_cards_set_name'), table_name='cards')
op.drop_index(op.f('ix_cards_name'), table_name='cards')
op.drop_index(op.f('ix_cards_id'), table_name='cards')
op.drop_table('cards')
op.drop_index(op.f('ix_boxes_id'), table_name='boxes')
op.drop_table('boxes')
# ### end Alembic commands ###

View File

@ -0,0 +1,49 @@
"""create tcgplayer categories table
Revision ID: create_tcgplayer_categories_table
Revises: remove_product_id_unique_constraint
Create Date: 2025-04-09 23:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'create_tcgplayer_categories_table'
down_revision: str = 'remove_product_id_unique_constraint'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table('tcgplayer_categories',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('category_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('display_name', sa.String(), nullable=True),
sa.Column('seo_category_name', sa.String(), nullable=True),
sa.Column('category_description', sa.String(), nullable=True),
sa.Column('category_page_title', sa.String(), nullable=True),
sa.Column('sealed_label', sa.String(), nullable=True),
sa.Column('non_sealed_label', sa.String(), nullable=True),
sa.Column('condition_guide_url', sa.String(), nullable=True),
sa.Column('is_scannable', sa.Boolean(), nullable=True, default=False),
sa.Column('popularity', sa.Integer(), nullable=True, default=0),
sa.Column('is_direct', sa.Boolean(), nullable=True, default=False),
sa.Column('modified_on', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('category_id')
)
op.create_index('ix_tcgplayer_categories_id', 'tcgplayer_categories', ['id'], unique=False)
op.create_index('ix_tcgplayer_categories_category_id', 'tcgplayer_categories', ['category_id'], unique=True)
def downgrade() -> None:
op.drop_index('ix_tcgplayer_categories_category_id', table_name='tcgplayer_categories')
op.drop_index('ix_tcgplayer_categories_id', table_name='tcgplayer_categories')
op.drop_table('tcgplayer_categories')

View File

@ -0,0 +1,101 @@
"""remove product_id unique constraint
Revision ID: remove_product_id_unique_constraint
Revises: 4dbeb89dd33a
Create Date: 2025-04-09 23:10:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'remove_product_id_unique_constraint'
down_revision: str = '4dbeb89dd33a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create a new table without the unique constraint
op.create_table('tcgplayer_products_new',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=True),
sa.Column('name', sa.String(), nullable=False),
sa.Column('clean_name', sa.String(), nullable=True),
sa.Column('image_url', sa.String(), nullable=True),
sa.Column('category_id', sa.Integer(), nullable=True),
sa.Column('group_id', sa.Integer(), nullable=True),
sa.Column('url', sa.String(), nullable=True),
sa.Column('modified_on', sa.DateTime(), nullable=True),
sa.Column('image_count', sa.Integer(), nullable=True),
sa.Column('ext_rarity', sa.String(), nullable=True),
sa.Column('ext_number', sa.String(), nullable=True),
sa.Column('low_price', sa.Float(), nullable=True),
sa.Column('mid_price', sa.Float(), nullable=True),
sa.Column('high_price', sa.Float(), nullable=True),
sa.Column('market_price', sa.Float(), nullable=True),
sa.Column('direct_low_price', sa.Float(), nullable=True),
sa.Column('sub_type_name', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['group_id'], ['tcgplayer_groups.group_id'])
)
# Copy data from old table to new table
op.execute('INSERT INTO tcgplayer_products_new SELECT * FROM tcgplayer_products')
# Drop old table
op.drop_table('tcgplayer_products')
# Rename new table to old table name
op.rename_table('tcgplayer_products_new', 'tcgplayer_products')
# Create indexes
op.create_index('ix_tcgplayer_products_id', 'tcgplayer_products', ['id'], unique=False)
op.create_index('ix_tcgplayer_products_product_id', 'tcgplayer_products', ['product_id'], unique=False)
def downgrade() -> None:
# Create a new table with the unique constraint
op.create_table('tcgplayer_products_new',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=True),
sa.Column('name', sa.String(), nullable=False),
sa.Column('clean_name', sa.String(), nullable=True),
sa.Column('image_url', sa.String(), nullable=True),
sa.Column('category_id', sa.Integer(), nullable=True),
sa.Column('group_id', sa.Integer(), nullable=True),
sa.Column('url', sa.String(), nullable=True),
sa.Column('modified_on', sa.DateTime(), nullable=True),
sa.Column('image_count', sa.Integer(), nullable=True),
sa.Column('ext_rarity', sa.String(), nullable=True),
sa.Column('ext_number', sa.String(), nullable=True),
sa.Column('low_price', sa.Float(), nullable=True),
sa.Column('mid_price', sa.Float(), nullable=True),
sa.Column('high_price', sa.Float(), nullable=True),
sa.Column('market_price', sa.Float(), nullable=True),
sa.Column('direct_low_price', sa.Float(), nullable=True),
sa.Column('sub_type_name', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('product_id'),
sa.ForeignKeyConstraint(['group_id'], ['tcgplayer_groups.group_id'])
)
# Copy data from old table to new table
op.execute('INSERT INTO tcgplayer_products_new SELECT * FROM tcgplayer_products')
# Drop old table
op.drop_table('tcgplayer_products')
# Rename new table to old table name
op.rename_table('tcgplayer_products_new', 'tcgplayer_products')
# Create indexes
op.create_index('ix_tcgplayer_products_id', 'tcgplayer_products', ['id'], unique=False)
op.create_index('ix_tcgplayer_products_product_id', 'tcgplayer_products', ['product_id'], unique=True)

20
app.log Normal file
View File

@ -0,0 +1,20 @@
2025-04-09 23:51:18,179 - INFO - app.main - Application starting up...
2025-04-09 23:51:18,188 - INFO - app.main - Database initialized successfully
2025-04-09 23:51:22,506 - INFO - app.services.external_api.base_external_service - Making request to https://tcgcsv.com/tcgplayer/1/23167/ProductsAndPrices.csv
2025-04-09 23:51:23,897 - INFO - app.services.external_api.base_external_service - Making request to https://tcgcsv.com/tcgplayer/1/3100/ProductsAndPrices.csv
2025-04-09 23:51:29,067 - INFO - app.services.external_api.base_external_service - Making request to https://tcgcsv.com/tcgplayer/1/2572/ProductsAndPrices.csv
2025-04-09 23:51:37,721 - INFO - app.services.external_api.base_external_service - Making request to https://tcgcsv.com/tcgplayer/1/14/ProductsAndPrices.csv
2025-04-09 23:51:38,716 - INFO - app.services.external_api.base_external_service - Making request to https://tcgcsv.com/tcgplayer/1/29/ProductsAndPrices.csv
2025-04-09 23:51:38,737 - INFO - app.services.external_api.base_external_service - Making request to https://tcgcsv.com/tcgplayer/1/64/ProductsAndPrices.csv
2025-04-09 23:51:44,117 - INFO - app.services.external_api.base_external_service - Making request to https://tcgcsv.com/tcgplayer/3/609/ProductsAndPrices.csv
2025-04-09 23:51:44,140 - INFO - app.services.external_api.base_external_service - Making request to https://tcgcsv.com/tcgplayer/3/610/ProductsAndPrices.csv
2025-04-09 23:51:45,264 - INFO - app.main - TCGPlayer data initialized successfully
2025-04-09 23:51:45,265 - INFO - apscheduler.scheduler - Adding job tentatively -- it will be properly scheduled when the scheduler starts
2025-04-09 23:51:45,265 - INFO - app.services.scheduler.base_scheduler - Scheduled task process_tcgplayer_export to run every 86400 seconds
2025-04-09 23:51:45,265 - INFO - apscheduler.scheduler - Added job "SchedulerService.process_tcgplayer_export" to job store "default"
2025-04-09 23:51:45,265 - INFO - apscheduler.scheduler - Scheduler started
2025-04-09 23:51:45,265 - INFO - app.services.scheduler.base_scheduler - Scheduler started
2025-04-09 23:51:45,265 - INFO - app.services.scheduler.scheduler_service - All scheduled tasks started
2025-04-09 23:51:45,266 - INFO - app.services.scheduler.scheduler_service - Starting scheduled TCGPlayer export processing for live
2025-04-09 23:51:51,510 - INFO - app.services.scheduler.scheduler_service - Completed TCGPlayer export processing: {'total_rows': 5793, 'processed_rows': 5793, 'errors': 0, 'error_messages': []}
2025-04-09 23:51:51,510 - INFO - app.main - Scheduler started successfully

View File

@ -34,6 +34,8 @@ def transaction(db: Session):
db.commit()
except Exception as e:
db.rollback()
# Reset the session state
db.expire_all()
raise e
def init_db():

View File

@ -1,22 +1,77 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import uvicorn
import logging
from routes import routes
from db.database import init_db
from app.models.card import Card # Assuming you have a Card model
import os
from app.routes import routes
from app.db.database import init_db, SessionLocal
from app.services.scheduler.scheduler_service import SchedulerService
from app.services.data_initialization import DataInitializationService
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[logging.StreamHandler(), logging.FileHandler("app.log")],)
# Configure logging
log_file = "app.log"
if os.path.exists(log_file):
os.remove(log_file) # Remove existing log file to start fresh
# Create a formatter
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(name)s - %(message)s')
# Create handlers
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
file_handler = logging.FileHandler(log_file, mode='w', encoding='utf-8')
file_handler.setFormatter(formatter)
# Configure root logger
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
root_logger.addHandler(console_handler)
root_logger.addHandler(file_handler)
# Get logger for this module
logger = logging.getLogger(__name__)
logger.info("Application starting up...")
# Initialize scheduler service
scheduler_service = SchedulerService()
data_init_service = DataInitializationService()
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
init_db()
logger.info("Database initialized successfully")
# Initialize TCGPlayer data
db = SessionLocal()
try:
await data_init_service.initialize_data(db, game_ids=[1, 3]) # 1 = Magic, 3 = Pokemon
logger.info("TCGPlayer data initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize TCGPlayer data: {str(e)}")
finally:
db.close()
# Start the scheduler
await scheduler_service.start_scheduled_tasks()
await scheduler_service.process_tcgplayer_export(export_type="live", use_cache=True)
logger.info("Scheduler started successfully")
yield
# Shutdown
await scheduler_service.scheduler.shutdown()
await data_init_service.close()
logger.info("Scheduler shut down")
logger.info("Database connection closed")
app = FastAPI(
title="CCR Cards Management API",
description="API for managing CCR Cards Inventory, Orders, and more.",
version="0.1.0",
lifespan=lifespan
)
app.add_middleware(
@ -29,17 +84,5 @@ app.add_middleware(
app.include_router(routes.router)
@app.on_event("startup")
async def on_startup():
init_db()
logger.info("Database initialized successfully")
@app.on_event("shutdown")
async def on_shutdown():
logger.info("Database connection closed")
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000, reload=True)
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)

View File

@ -12,16 +12,13 @@ class Box(Base):
set_code = Column(String)
sku = Column(Integer)
name = Column(String)
game_id = Column(Integer, ForeignKey("games.id"))
expected_number_of_cards = Column(Integer)
description = Column(String)
image_url = Column(String)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
cards = relationship("Card", back_populates="box")
open_boxes = relationship("OpenBox", back_populates="box")
class OpenBox(Base):
__tablename__ = "open_boxes"
@ -31,7 +28,3 @@ class OpenBox(Base):
date_opened = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
box = relationship("Box", back_populates="open_boxes")
cards = relationship("Card", back_populates="open_box")

View File

@ -1,4 +1,3 @@
from pydantic import BaseModel, ConfigDict
from typing import List, Optional
from datetime import datetime
from sqlalchemy import Column, Integer, String, Float, ForeignKey, DateTime
@ -13,11 +12,26 @@ class Card(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True)
rarity = Column(String)
set_name = Column(String)
set_name = Column(String, index=True)
price = Column(Float)
quantity = Column(Integer, default=0)
# TCGPlayer specific fields
tcgplayer_sku = Column(String, unique=True, index=True)
product_line = Column(String)
product_name = Column(String)
title = Column(String)
number = Column(String)
condition = Column(String)
tcg_market_price = Column(Float)
tcg_direct_low = Column(Float)
tcg_low_price_with_shipping = Column(Float)
tcg_low_price = Column(Float)
total_quantity = Column(Integer)
add_to_quantity = Column(Integer)
tcg_marketplace_price = Column(Float)
photo_url = Column(String)
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
orders = relationship("Order", secondary="order_cards", back_populates="cards")

View File

@ -16,6 +16,3 @@ class File(Base):
path = Column(String)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@ -1,6 +1,3 @@
from pydantic import BaseModel, ConfigDict
from typing import List, Optional
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
@ -15,6 +12,3 @@ class Game(Base):
image_url = Column(String)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
boxes = relationship("Box", back_populates="game")

28
app/models/inventory.py Normal file
View File

@ -0,0 +1,28 @@
from sqlalchemy import Column, Integer, String, Float, DateTime
from sqlalchemy.sql import func
from app.db.database import Base
class Inventory(Base):
__tablename__ = "inventory"
id = Column(Integer, primary_key=True, index=True)
tcgplayer_id = Column(String, unique=True, index=True)
product_line = Column(String)
set_name = Column(String)
product_name = Column(String)
title = Column(String)
number = Column(String)
rarity = Column(String)
condition = Column(String)
tcg_market_price = Column(Float)
tcg_direct_low = Column(Float)
tcg_low_price_with_shipping = Column(Float)
tcg_low_price = Column(Float)
total_quantity = Column(Integer)
add_to_quantity = Column(Integer)
tcg_marketplace_price = Column(Float)
photo_url = Column(String)
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

25
app/models/order.py Normal file
View File

@ -0,0 +1,25 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from datetime import datetime
from app.db.database import Base
class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True)
customer_name = Column(String, index=True)
customer_email = Column(String)
total_amount = Column(Float)
status = Column(String, default="pending") # pending, processing, shipped, delivered, cancelled
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class OrderCard(Base):
__tablename__ = "order_cards"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"))
card_id = Column(Integer, ForeignKey("cards.id"))
quantity = Column(Integer, default=1)
price_at_time = Column(Float) # Price of the card when ordered
created_at = Column(DateTime, default=datetime.utcnow)

View File

@ -0,0 +1,23 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.sql import func
from app.db.database import Base
class TCGPlayerCategory(Base):
__tablename__ = "tcgplayer_categories"
id = Column(Integer, primary_key=True, index=True)
category_id = Column(Integer, unique=True, index=True)
name = Column(String, nullable=False)
display_name = Column(String)
seo_category_name = Column(String)
category_description = Column(String)
category_page_title = Column(String)
sealed_label = Column(String)
non_sealed_label = Column(String)
condition_guide_url = Column(String)
is_scannable = Column(Boolean, default=False)
popularity = Column(Integer, default=0)
is_direct = Column(Boolean, default=False)
modified_on = Column(DateTime)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@ -0,0 +1,17 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.sql import func
from app.db.database import Base
class TCGPlayerGroup(Base):
__tablename__ = "tcgplayer_groups"
id = Column(Integer, primary_key=True, index=True)
group_id = Column(Integer, unique=True, index=True)
name = Column(String, nullable=False)
abbreviation = Column(String)
is_supplemental = Column(Boolean, default=False)
published_on = Column(DateTime)
modified_on = Column(DateTime)
category_id = Column(Integer)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@ -0,0 +1,27 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
from sqlalchemy.sql import func
from app.db.database import Base
class TCGPlayerProduct(Base):
__tablename__ = "tcgplayer_products"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, index=True)
name = Column(String, nullable=False)
clean_name = Column(String)
image_url = Column(String)
category_id = Column(Integer)
group_id = Column(Integer, ForeignKey("tcgplayer_groups.group_id"))
url = Column(String)
modified_on = Column(DateTime)
image_count = Column(Integer)
ext_rarity = Column(String)
ext_number = Column(String)
low_price = Column(Float)
mid_price = Column(Float)
high_price = Column(Float)
market_price = Column(Float)
direct_low_price = Column(Float)
sub_type_name = Column(String)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@ -1,17 +1,25 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.db.database import get_db
from app.models.file import File, FileCreate, FileUpdate, FileDelete, FileList
from app.models.box import Box, BoxCreate, BoxUpdate, BoxDelete, BoxList, OpenBox, OpenBoxCreate, OpenBoxUpdate, OpenBoxDelete, OpenBoxList
from app.models.game import Game, GameCreate, GameUpdate, GameDelete, GameList
from app.models.card import Card, CardCreate, CardUpdate, CardDelete, CardList
from app.models.file import File as FileModel
from app.schemas.file import FileCreate, FileUpdate, FileDelete, FileList, FileInDB
from app.models.box import Box as BoxModel, OpenBox as OpenBoxModel
from app.schemas.box import BoxCreate, BoxUpdate, BoxDelete, BoxList, OpenBoxCreate, OpenBoxUpdate, OpenBoxDelete, OpenBoxList, BoxInDB, OpenBoxInDB
from app.models.game import Game as GameModel
from app.schemas.game import GameCreate, GameUpdate, GameDelete, GameList, GameInDB
from app.models.card import Card as CardModel
from app.schemas.card import CardCreate, CardUpdate, CardDelete, CardList, CardInDB
from app.services import CardService, OrderService
from app.services.file_processing_service import FileProcessingService
from app.services.external_api.tcgplayer.tcgplayer_inventory_service import TCGPlayerInventoryService
router = APIRouter(prefix="/api")
# Initialize services
card_service = CardService()
order_service = OrderService()
file_processing_service = FileProcessingService()
tcgplayer_inventory_service = TCGPlayerInventoryService()
# ============================================================================
# Health Check & Root Endpoints
@ -37,7 +45,7 @@ async def get_cards(
):
skip = (page - 1) * limit
cards = card_service.get_all(db, skip=skip, limit=limit)
total = db.query(Card).count()
total = db.query(CardModel).count()
return {
"cards": cards,
"total": total,
@ -45,7 +53,7 @@ async def get_cards(
"limit": limit
}
@router.post("/cards", response_model=Card)
@router.post("/cards", response_model=CardInDB)
async def create_card(card: CardCreate, db: Session = Depends(get_db)):
try:
created_card = card_service.create(db, card.dict())
@ -53,7 +61,7 @@ async def create_card(card: CardCreate, db: Session = Depends(get_db)):
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/cards/{card_id}", response_model=Card)
@router.put("/cards/{card_id}", response_model=CardInDB)
async def update_card(card_id: int, card: CardUpdate, db: Session = Depends(get_db)):
db_card = card_service.get(db, card_id)
if not db_card:
@ -105,11 +113,11 @@ async def get_orders(
async def get_files(page: int = 1, limit: int = 10, type: str = None, id: int = None):
return {"files": [], "total": 0, "page": page, "limit": limit}
@router.post("/files", response_model=File)
@router.post("/files", response_model=FileInDB)
async def create_file(file: FileCreate):
return {"message": "File created successfully"}
@router.put("/files/{file_id}", response_model=File)
@router.put("/files/{file_id}", response_model=FileInDB)
async def update_file(file_id: int, file: FileUpdate):
return {"message": "File updated successfully"}
@ -124,11 +132,11 @@ async def delete_file(file_id: int):
async def get_boxes(page: int = 1, limit: int = 10, type: str = None, id: int = None):
return {"boxes": [], "total": 0, "page": page, "limit": limit}
@router.post("/boxes", response_model=Box)
@router.post("/boxes", response_model=BoxInDB)
async def create_box(box: BoxCreate):
return {"message": "Box created successfully"}
@router.put("/boxes/{box_id}", response_model=Box)
@router.put("/boxes/{box_id}", response_model=BoxInDB)
async def update_box(box_id: int, box: BoxUpdate):
return {"message": "Box updated successfully"}
@ -143,11 +151,11 @@ async def delete_box(box_id: int):
async def get_open_boxes(page: int = 1, limit: int = 10, type: str = None, id: int = None):
return {"open_boxes": [], "total": 0, "page": page, "limit": limit}
@router.post("/open_boxes", response_model=OpenBox)
@router.post("/open_boxes", response_model=OpenBoxInDB)
async def create_open_box(open_box: OpenBoxCreate):
return {"message": "Open box created successfully"}
@router.put("/open_boxes/{open_box_id}", response_model=OpenBox)
@router.put("/open_boxes/{open_box_id}", response_model=OpenBoxInDB)
async def update_open_box(open_box_id: int, open_box: OpenBoxUpdate):
return {"message": "Open box updated successfully"}
@ -162,11 +170,11 @@ async def delete_open_box(open_box_id: int):
async def get_games(page: int = 1, limit: int = 10, type: str = None, id: int = None):
return {"games": [], "total": 0, "page": page, "limit": limit}
@router.post("/games", response_model=Game)
@router.post("/games", response_model=GameInDB)
async def create_game(game: GameCreate):
return {"message": "Game created successfully"}
@router.put("/games/{game_id}", response_model=Game)
@router.put("/games/{game_id}", response_model=GameInDB)
async def update_game(game_id: int, game: GameUpdate):
return {"message": "Game updated successfully"}
@ -174,3 +182,26 @@ async def update_game(game_id: int, game: GameUpdate):
async def delete_game(game_id: int):
return {"message": "Game deleted successfully"}
@router.post("/tcgplayer/process-export")
async def process_tcgplayer_export(export_type: str, db: Session = Depends(get_db)):
"""
Download and process a TCGPlayer export file.
Args:
export_type: Type of export to process (staged, live, or pricing)
db: Database session
"""
try:
# Download the file
file_bytes = await tcgplayer_inventory_service.get_tcgplayer_export(export_type)
# Process the file and load into database
stats = await file_processing_service.process_tcgplayer_export(db, file_bytes)
return {
"message": "Export processed successfully",
"stats": stats
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@ -1,50 +1,41 @@
from pydantic import BaseModel, ConfigDict
from typing import Optional, List
from typing import List, Optional
from datetime import datetime
from pydantic import BaseModel
# Base schema with common attributes
class BoxBase(BaseModel):
product_id: int
type: str
set_code: str
sku: int
name: str
expected_number_of_cards: int
description: str
image_url: str
description: Optional[str] = None
game_id: int
set_id: Optional[int] = None
price: Optional[float] = None
quantity: Optional[int] = 0
status: Optional[str] = "available" # available, sold, reserved
# Schema for creating a new box
class BoxCreate(BoxBase):
pass
# Schema for updating a box
class BoxUpdate(BaseModel):
product_id: Optional[int] = None
type: Optional[str] = None
set_code: Optional[str] = None
sku: Optional[int] = None
name: Optional[str] = None
expected_number_of_cards: Optional[int] = None
description: Optional[str] = None
image_url: Optional[str] = None
class BoxUpdate(BoxBase):
pass
# Schema for reading a box
class Box(BoxBase):
class BoxInDB(BoxBase):
id: int
created_at: datetime
updated_at: datetime
cards: List["Card"] = []
open_boxes: List["OpenBox"] = []
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class Config:
from_attributes = True
# Schema for deleting a box
class BoxDelete(BaseModel):
id: int
message: str
# Schema for listing boxes
class BoxList(BaseModel):
boxes: List[Box]
boxes: List[BoxInDB]
total: int
page: int
limit: int
@ -52,30 +43,30 @@ class BoxList(BaseModel):
# OpenBox schemas
class OpenBoxBase(BaseModel):
box_id: int
number_of_cards: int
date_opened: datetime
opened_at: Optional[datetime] = None
opened_by: Optional[str] = None
contents: Optional[List[dict]] = None
status: Optional[str] = "pending" # pending, opened, verified, listed
class OpenBoxCreate(OpenBoxBase):
pass
class OpenBoxUpdate(BaseModel):
number_of_cards: Optional[int] = None
date_opened: Optional[datetime] = None
class OpenBoxUpdate(OpenBoxBase):
pass
class OpenBox(OpenBoxBase):
class OpenBoxInDB(OpenBoxBase):
id: int
created_at: datetime
updated_at: datetime
box: Optional[Box] = None
cards: List["Card"] = []
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class Config:
from_attributes = True
class OpenBoxDelete(BaseModel):
id: int
message: str
class OpenBoxList(BaseModel):
open_boxes: List[OpenBox]
open_boxes: List[OpenBoxInDB]
total: int
page: int
limit: int

View File

@ -1,39 +1,55 @@
from pydantic import BaseModel, ConfigDict
from typing import Optional, List
from typing import List, Optional
from datetime import datetime
from pydantic import BaseModel
# Base schema with common attributes
class CardBase(BaseModel):
name: str
rarity: str
set_name: str
price: float
quantity: int = 0
rarity: Optional[str] = None
set_name: Optional[str] = None
price: Optional[float] = None
quantity: Optional[int] = 0
# TCGPlayer specific fields
tcgplayer_sku: Optional[str] = None
product_line: Optional[str] = None
product_name: Optional[str] = None
title: Optional[str] = None
number: Optional[str] = None
condition: Optional[str] = None
tcg_market_price: Optional[float] = None
tcg_direct_low: Optional[float] = None
tcg_low_price_with_shipping: Optional[float] = None
tcg_low_price: Optional[float] = None
total_quantity: Optional[int] = None
add_to_quantity: Optional[int] = None
tcg_marketplace_price: Optional[float] = None
photo_url: Optional[str] = None
# Schema for creating a new card
class CardCreate(CardBase):
pass
# Schema for updating a card
class CardUpdate(BaseModel):
name: Optional[str] = None
rarity: Optional[str] = None
set_name: Optional[str] = None
price: Optional[float] = None
quantity: Optional[int] = None
class CardUpdate(CardBase):
pass
# Schema for reading a card (includes id and relationships)
class Card(CardBase):
class CardInDB(CardBase):
id: int
created_at: datetime
updated_at: datetime
orders: List["Order"] = []
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class Config:
from_attributes = True
# Schema for listing cards
class CardList(BaseModel):
cards: List[Card]
cards: List[CardInDB]
total: int
page: int
limit: int
# Schema for deleting a card
class CardDelete(BaseModel):
message: str

View File

@ -1,38 +1,40 @@
from pydantic import BaseModel, ConfigDict
from typing import Optional, List
from typing import List, Optional
from datetime import datetime
from pydantic import BaseModel
# Base schema with common attributes
class FileBase(BaseModel):
name: str
type: str
path: str
type: Optional[str] = None
size: Optional[int] = None
content_type: Optional[str] = None
metadata: Optional[dict] = None
# Schema for creating a new file
class FileCreate(FileBase):
pass
# Schema for updating a file
class FileUpdate(BaseModel):
name: Optional[str] = None
type: Optional[str] = None
path: Optional[str] = None
class FileUpdate(FileBase):
pass
# Schema for reading a file
class File(FileBase):
class FileInDB(FileBase):
id: int
created_at: datetime
updated_at: datetime
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class Config:
from_attributes = True
# Schema for deleting a file
class FileDelete(BaseModel):
id: int
message: str
# Schema for listing files
class FileList(BaseModel):
files: List[File]
files: List[FileInDB]
total: int
page: int
limit: int

View File

@ -1,39 +1,41 @@
from pydantic import BaseModel, ConfigDict
from typing import Optional, List
from typing import List, Optional
from datetime import datetime
from pydantic import BaseModel
# Base schema with common attributes
class GameBase(BaseModel):
name: str
description: str
image_url: str
publisher: Optional[str] = None
release_date: Optional[datetime] = None
description: Optional[str] = None
website: Optional[str] = None
logo_url: Optional[str] = None
status: Optional[str] = "active" # active, inactive, discontinued
# Schema for creating a new game
class GameCreate(GameBase):
pass
# Schema for updating a game
class GameUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
image_url: Optional[str] = None
class GameUpdate(GameBase):
pass
# Schema for reading a game
class Game(GameBase):
class GameInDB(GameBase):
id: int
created_at: datetime
updated_at: datetime
boxes: List["Box"] = []
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class Config:
from_attributes = True
# Schema for deleting a game
class GameDelete(BaseModel):
id: int
message: str
# Schema for listing games
class GameList(BaseModel):
games: List[Game]
games: List[GameInDB]
total: int
page: int
limit: int

View File

@ -1,5 +1,15 @@
from app.services.base_service import BaseService
from app.services.card_service import CardService
from app.services.order_service import OrderService
from app.services.file_processing_service import FileProcessingService
from app.services.inventory_service import InventoryService
from app.services.service_registry import ServiceRegistry
__all__ = ["BaseService", "CardService", "ServiceRegistry"]
__all__ = [
'BaseService',
'CardService',
'OrderService',
'FileProcessingService',
'InventoryService',
'ServiceRegistry'
]

View File

@ -1,17 +1,126 @@
from typing import List, Optional
from typing import List, Optional, Dict
from sqlalchemy.orm import Session
from app.services.base_service import BaseService
from app.models.card import Card
from app.services.base_service import BaseService
from app.schemas.card import CardCreate, CardUpdate
class CardService(BaseService[Card]):
def __init__(self):
super().__init__(Card)
def create(self, db: Session, obj_in: Dict) -> Card:
"""
Create a new card in the database.
Args:
db: Database session
obj_in: Dictionary containing card data
Returns:
Card: The created card object
"""
return super().create(db, obj_in)
def update(self, db: Session, db_obj: Card, obj_in: Dict) -> Card:
"""
Update an existing card in the database.
Args:
db: Database session
db_obj: The card object to update
obj_in: Dictionary containing updated card data
Returns:
Card: The updated card object
"""
return super().update(db, db_obj, obj_in)
def get_by_name(self, db: Session, name: str) -> Optional[Card]:
"""
Get a card by its name.
Args:
db: Database session
name: The name of the card to find
Returns:
Optional[Card]: The card if found, None otherwise
"""
return db.query(self.model).filter(self.model.name == name).first()
def get_by_set(self, db: Session, set_name: str, skip: int = 0, limit: int = 100) -> List[Card]:
"""
Get all cards from a specific set.
Args:
db: Database session
set_name: The name of the set to filter by
skip: Number of records to skip (for pagination)
limit: Maximum number of records to return
Returns:
List[Card]: List of cards from the specified set
"""
return db.query(self.model).filter(self.model.set_name == set_name).offset(skip).limit(limit).all()
def get_by_rarity(self, db: Session, rarity: str, skip: int = 0, limit: int = 100) -> List[Card]:
"""
Get all cards of a specific rarity.
Args:
db: Database session
rarity: The rarity to filter by
skip: Number of records to skip (for pagination)
limit: Maximum number of records to return
Returns:
List[Card]: List of cards with the specified rarity
"""
return db.query(self.model).filter(self.model.rarity == rarity).offset(skip).limit(limit).all()
def get_by_set(self, db: Session, set_name: str, skip: int = 0, limit: int = 100) -> List[Card]:
return db.query(self.model).filter(self.model.set_name == set_name).offset(skip).limit(limit).all()
def update_quantity(self, db: Session, card_id: int, quantity_change: int) -> Card:
"""
Update the quantity of a card.
Args:
db: Database session
card_id: The ID of the card to update
quantity_change: The amount to change the quantity by (can be positive or negative)
Returns:
Card: The updated card object
Raises:
ValueError: If the card is not found or if the resulting quantity would be negative
"""
card = self.get(db, card_id)
if not card:
raise ValueError(f"Card with ID {card_id} not found")
new_quantity = card.quantity + quantity_change
if new_quantity < 0:
raise ValueError(f"Cannot reduce quantity below 0. Current quantity: {card.quantity}, attempted change: {quantity_change}")
card.quantity = new_quantity
db.add(card)
db.commit()
db.refresh(card)
return card
def search(self, db: Session, query: str, skip: int = 0, limit: int = 100) -> List[Card]:
"""
Search for cards by name or set name.
Args:
db: Database session
query: The search query
skip: Number of records to skip (for pagination)
limit: Maximum number of records to return
Returns:
List[Card]: List of cards matching the search query
"""
return db.query(self.model).filter(
(self.model.name.ilike(f"%{query}%")) |
(self.model.set_name.ilike(f"%{query}%"))
).offset(skip).limit(limit).all()

View File

@ -0,0 +1,201 @@
import os
import json
from datetime import datetime
from typing import Optional, List
from sqlalchemy.orm import Session
from app.services.external_api.tcgcsv.tcgcsv_service import TCGCSVService
from app.models.tcgplayer_group import TCGPlayerGroup
from app.models.tcgplayer_product import TCGPlayerProduct
from app.models.tcgplayer_category import TCGPlayerCategory
class DataInitializationService:
def __init__(self, cache_dir: str = "app/data/cache/tcgcsv"):
self.cache_dir = cache_dir
self.tcgcsv_service = TCGCSVService()
os.makedirs(cache_dir, exist_ok=True)
def _get_cache_path(self, filename: str) -> str:
return os.path.join(self.cache_dir, filename)
async def _cache_categories(self, categories_data: dict):
"""Cache categories data to a JSON file"""
cache_path = self._get_cache_path("categories.json")
with open(cache_path, 'w') as f:
json.dump(categories_data, f, indent=2)
async def _cache_groups(self, game_ids: List[int], groups_data: dict):
for game_id in game_ids:
cache_path = self._get_cache_path(f"groups_{game_id}.json")
with open(cache_path, 'w') as f:
json.dump(groups_data, f, default=str)
async def _cache_products(self, game_ids: List[int], group_id: int, products_data: list):
for game_id in game_ids:
cache_path = self._get_cache_path(f"products_{game_id}_{group_id}.json")
with open(cache_path, 'w') as f:
json.dump(products_data, f, default=str)
async def _load_cached_categories(self) -> Optional[dict]:
cache_path = self._get_cache_path("categories.json")
if os.path.exists(cache_path):
with open(cache_path, 'r') as f:
return json.load(f)
return None
async def _load_cached_groups(self, game_ids: List[int]) -> Optional[dict]:
# Try to load cached data for any of the game IDs
for game_id in game_ids:
cache_path = self._get_cache_path(f"groups_{game_id}.json")
if os.path.exists(cache_path):
with open(cache_path, 'r') as f:
return json.load(f)
return None
async def _load_cached_products(self, game_ids: List[int], group_id: int) -> Optional[list]:
# Try to load cached data for any of the game IDs
for game_id in game_ids:
cache_path = self._get_cache_path(f"products_{game_id}_{group_id}.json")
if os.path.exists(cache_path):
with open(cache_path, 'r') as f:
return json.load(f)
return None
async def initialize_data(self, db: Session, game_ids: List[int], use_cache: bool = True) -> None:
"""Initialize TCGPlayer data, using cache if available and requested"""
print("Initializing TCGPlayer data...")
# Handle categories
categories_data = None
if use_cache:
categories_data = await self._load_cached_categories()
if not categories_data:
print("Fetching categories from API...")
categories_data = await self.tcgcsv_service.get_categories()
if use_cache:
await self._cache_categories(categories_data)
if not categories_data.get("success"):
raise Exception(f"Failed to fetch categories: {categories_data.get('errors')}")
# Sync categories to database
categories = categories_data.get("results", [])
synced_categories = []
for category_data in categories:
existing_category = db.query(TCGPlayerCategory).filter(TCGPlayerCategory.category_id == category_data["categoryId"]).first()
if existing_category:
synced_categories.append(existing_category)
else:
new_category = TCGPlayerCategory(
category_id=category_data["categoryId"],
name=category_data["name"],
display_name=category_data.get("displayName"),
seo_category_name=category_data.get("seoCategoryName"),
category_description=category_data.get("categoryDescription"),
category_page_title=category_data.get("categoryPageTitle"),
sealed_label=category_data.get("sealedLabel"),
non_sealed_label=category_data.get("nonSealedLabel"),
condition_guide_url=category_data.get("conditionGuideUrl"),
is_scannable=category_data.get("isScannable", False),
popularity=category_data.get("popularity", 0),
is_direct=category_data.get("isDirect", False),
modified_on=datetime.fromisoformat(category_data["modifiedOn"].replace("Z", "+00:00")) if category_data.get("modifiedOn") else None
)
db.add(new_category)
synced_categories.append(new_category)
db.commit()
print(f"Synced {len(synced_categories)} categories")
# Process each game ID separately
for game_id in game_ids:
print(f"\nProcessing game ID: {game_id}")
# Handle groups for this game ID
groups_data = None
if use_cache:
groups_data = await self._load_cached_groups([game_id])
if not groups_data:
print(f"Fetching groups for game ID {game_id} from API...")
groups_data = await self.tcgcsv_service.get_groups([game_id])
if use_cache:
await self._cache_groups([game_id], groups_data)
if not groups_data.get("success"):
raise Exception(f"Failed to fetch groups for game ID {game_id}: {groups_data.get('errors')}")
# Sync groups to database
groups = groups_data.get("results", [])
synced_groups = []
for group_data in groups:
existing_group = db.query(TCGPlayerGroup).filter(TCGPlayerGroup.group_id == group_data["groupId"]).first()
if existing_group:
synced_groups.append(existing_group)
else:
new_group = TCGPlayerGroup(
group_id=group_data["groupId"],
name=group_data["name"],
abbreviation=group_data.get("abbreviation"),
is_supplemental=group_data.get("isSupplemental", False),
published_on=datetime.fromisoformat(group_data["publishedOn"].replace("Z", "+00:00")) if group_data.get("publishedOn") else None,
modified_on=datetime.fromisoformat(group_data["modifiedOn"].replace("Z", "+00:00")) if group_data.get("modifiedOn") else None,
category_id=group_data.get("categoryId")
)
db.add(new_group)
synced_groups.append(new_group)
db.commit()
print(f"Synced {len(synced_groups)} groups for game ID {game_id}")
# Handle products for each group in this game ID
for group in synced_groups:
products_data = None
if use_cache:
products_data = await self._load_cached_products([game_id], group.group_id)
if not products_data:
print(f"Fetching products for group {group.name} (game ID {game_id}) from API...")
products_data = await self.tcgcsv_service.get_products_and_prices([game_id], group.group_id)
if use_cache:
await self._cache_products([game_id], group.group_id, products_data)
# Sync products to database
synced_products = []
for product_data in products_data:
existing_product = db.query(TCGPlayerProduct).filter(TCGPlayerProduct.product_id == int(product_data["productId"])).first()
if existing_product:
synced_products.append(existing_product)
else:
new_product = TCGPlayerProduct(
product_id=int(product_data["productId"]),
name=product_data["name"],
clean_name=product_data.get("cleanName"),
image_url=product_data.get("imageUrl"),
category_id=int(product_data["categoryId"]),
group_id=int(product_data["groupId"]),
url=product_data.get("url"),
modified_on=datetime.fromisoformat(product_data["modifiedOn"].replace("Z", "+00:00")) if product_data.get("modifiedOn") else None,
image_count=int(product_data.get("imageCount", 0)),
ext_rarity=product_data.get("extRarity"),
ext_number=product_data.get("extNumber"),
low_price=float(product_data.get("lowPrice")) if product_data.get("lowPrice") else None,
mid_price=float(product_data.get("midPrice")) if product_data.get("midPrice") else None,
high_price=float(product_data.get("highPrice")) if product_data.get("highPrice") else None,
market_price=float(product_data.get("marketPrice")) if product_data.get("marketPrice") else None,
direct_low_price=float(product_data.get("directLowPrice")) if product_data.get("directLowPrice") else None,
sub_type_name=product_data.get("subTypeName")
)
db.add(new_product)
synced_products.append(new_product)
db.commit()
print(f"Synced {len(synced_products)} products for group {group.name} (game ID {game_id})")
async def clear_cache(self) -> None:
"""Clear all cached data"""
for filename in os.listdir(self.cache_dir):
file_path = os.path.join(self.cache_dir, filename)
if os.path.isfile(file_path):
os.unlink(file_path)
print("Cache cleared")
async def close(self):
await self.tcgcsv_service.close()

View File

@ -1,7 +1,8 @@
from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, Union
import aiohttp
import logging
from app.services.service_registry import ServiceRegistry
import json
logger = logging.getLogger(__name__)
@ -24,8 +25,9 @@ class BaseExternalService:
endpoint: str,
params: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None,
data: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
data: Optional[Dict[str, Any]] = None,
content_type: str = "application/json"
) -> Union[Dict[str, Any], str]:
session = await self._get_session()
url = f"{self.base_url}{endpoint}"
@ -36,9 +38,30 @@ class BaseExternalService:
try:
async with session.request(method, url, params=params, headers=headers, json=data) as response:
response.raise_for_status()
return await response.json()
# Get the actual content type from the response
response_content_type = response.headers.get('content-type', '').lower()
logger.info(f"Making request to {url}")
# Get the raw response text first
raw_response = await response.text()
# Only try to parse as JSON if the content type indicates JSON
if 'application/json' in response_content_type or 'text/json' in response_content_type:
try:
# First try to parse the response directly
return await response.json()
except Exception as e:
try:
# If that fails, try parsing the raw text as JSON (in case it's double-encoded)
return json.loads(raw_response)
except Exception as e:
logger.error(f"Failed to parse JSON response: {e}")
return raw_response
return raw_response
except aiohttp.ClientError as e:
logger.error(f"API request failed: {str(e)}")
logger.error(f"Request failed: {e}")
raise
except Exception as e:
logger.error(f"Unexpected error during API request: {str(e)}")

View File

@ -0,0 +1,205 @@
from typing import List, Dict, Any
from datetime import datetime
import csv
import io
from app.services.external_api.base_external_service import BaseExternalService
from app.models.tcgplayer_group import TCGPlayerGroup
from app.models.tcgplayer_product import TCGPlayerProduct
from app.models.tcgplayer_category import TCGPlayerCategory
from sqlalchemy.orm import Session
class TCGCSVService(BaseExternalService):
def __init__(self):
super().__init__(base_url="https://tcgcsv.com/tcgplayer/")
async def get_groups(self, game_ids: List[int]) -> Dict[str, Any]:
"""Fetch groups for specific game IDs from TCGCSV API"""
game_ids_str = ",".join(map(str, game_ids))
endpoint = f"{game_ids_str}/groups"
return await self._make_request("GET", endpoint)
async def get_products_and_prices(self, game_ids: List[int], group_id: int) -> List[Dict[str, Any]]:
"""Fetch products and prices for a specific group from TCGCSV API"""
game_ids_str = ",".join(map(str, game_ids))
endpoint = f"{game_ids_str}/{group_id}/ProductsAndPrices.csv"
response = await self._make_request("GET", endpoint, headers={"Accept": "text/csv"})
# Parse CSV response
csv_data = io.StringIO(response)
reader = csv.DictReader(csv_data)
return list(reader)
async def get_categories(self) -> Dict[str, Any]:
"""Fetch all categories from TCGCSV API"""
endpoint = "categories"
return await self._make_request("GET", endpoint)
async def sync_groups_to_db(self, db: Session, game_ids: List[int]) -> List[TCGPlayerGroup]:
"""Fetch groups from API and sync them to the database"""
response = await self.get_groups(game_ids)
if not response.get("success"):
raise Exception(f"Failed to fetch groups: {response.get('errors')}")
groups = response.get("results", [])
synced_groups = []
for group_data in groups:
# Convert string dates to datetime objects
published_on = datetime.fromisoformat(group_data["publishedOn"].replace("Z", "+00:00")) if group_data.get("publishedOn") else None
modified_on = datetime.fromisoformat(group_data["modifiedOn"].replace("Z", "+00:00")) if group_data.get("modifiedOn") else None
# Check if group already exists
existing_group = db.query(TCGPlayerGroup).filter(TCGPlayerGroup.group_id == group_data["groupId"]).first()
if existing_group:
# Update existing group
for key, value in {
"name": group_data["name"],
"abbreviation": group_data.get("abbreviation"),
"is_supplemental": group_data.get("isSupplemental", False),
"published_on": published_on,
"modified_on": modified_on,
"category_id": group_data.get("categoryId")
}.items():
setattr(existing_group, key, value)
synced_groups.append(existing_group)
else:
# Create new group
new_group = TCGPlayerGroup(
group_id=group_data["groupId"],
name=group_data["name"],
abbreviation=group_data.get("abbreviation"),
is_supplemental=group_data.get("isSupplemental", False),
published_on=published_on,
modified_on=modified_on,
category_id=group_data.get("categoryId")
)
db.add(new_group)
synced_groups.append(new_group)
db.commit()
return synced_groups
async def sync_products_to_db(self, db: Session, game_id: int, group_id: int) -> List[TCGPlayerProduct]:
"""Fetch products and prices for a group and sync them to the database"""
products_data = await self.get_products_and_prices(game_id, group_id)
synced_products = []
for product_data in products_data:
# Convert string dates to datetime objects
modified_on = datetime.fromisoformat(product_data["modifiedOn"].replace("Z", "+00:00")) if product_data.get("modifiedOn") else None
# Convert price strings to floats, handling empty strings
def parse_price(price_str):
return float(price_str) if price_str else None
# Check if product already exists
existing_product = db.query(TCGPlayerProduct).filter(TCGPlayerProduct.product_id == int(product_data["productId"])).first()
if existing_product:
# Update existing product
for key, value in {
"name": product_data["name"],
"clean_name": product_data.get("cleanName"),
"image_url": product_data.get("imageUrl"),
"category_id": int(product_data["categoryId"]),
"group_id": int(product_data["groupId"]),
"url": product_data.get("url"),
"modified_on": modified_on,
"image_count": int(product_data.get("imageCount", 0)),
"ext_rarity": product_data.get("extRarity"),
"ext_number": product_data.get("extNumber"),
"low_price": parse_price(product_data.get("lowPrice")),
"mid_price": parse_price(product_data.get("midPrice")),
"high_price": parse_price(product_data.get("highPrice")),
"market_price": parse_price(product_data.get("marketPrice")),
"direct_low_price": parse_price(product_data.get("directLowPrice")),
"sub_type_name": product_data.get("subTypeName")
}.items():
setattr(existing_product, key, value)
synced_products.append(existing_product)
else:
# Create new product
new_product = TCGPlayerProduct(
product_id=int(product_data["productId"]),
name=product_data["name"],
clean_name=product_data.get("cleanName"),
image_url=product_data.get("imageUrl"),
category_id=int(product_data["categoryId"]),
group_id=int(product_data["groupId"]),
url=product_data.get("url"),
modified_on=modified_on,
image_count=int(product_data.get("imageCount", 0)),
ext_rarity=product_data.get("extRarity"),
ext_number=product_data.get("extNumber"),
low_price=parse_price(product_data.get("lowPrice")),
mid_price=parse_price(product_data.get("midPrice")),
high_price=parse_price(product_data.get("highPrice")),
market_price=parse_price(product_data.get("marketPrice")),
direct_low_price=parse_price(product_data.get("directLowPrice")),
sub_type_name=product_data.get("subTypeName")
)
db.add(new_product)
synced_products.append(new_product)
db.commit()
return synced_products
async def sync_categories_to_db(self, db: Session) -> List[TCGPlayerCategory]:
"""Fetch categories from API and sync them to the database"""
response = await self.get_categories()
if not response.get("success"):
raise Exception(f"Failed to fetch categories: {response.get('errors')}")
categories = response.get("results", [])
synced_categories = []
for category_data in categories:
# Convert string dates to datetime objects
modified_on = datetime.fromisoformat(category_data["modifiedOn"].replace("Z", "+00:00")) if category_data.get("modifiedOn") else None
# Check if category already exists
existing_category = db.query(TCGPlayerCategory).filter(TCGPlayerCategory.category_id == category_data["categoryId"]).first()
if existing_category:
# Update existing category
for key, value in {
"name": category_data["name"],
"display_name": category_data.get("displayName"),
"seo_category_name": category_data.get("seoCategoryName"),
"category_description": category_data.get("categoryDescription"),
"category_page_title": category_data.get("categoryPageTitle"),
"sealed_label": category_data.get("sealedLabel"),
"non_sealed_label": category_data.get("nonSealedLabel"),
"condition_guide_url": category_data.get("conditionGuideUrl"),
"is_scannable": category_data.get("isScannable", False),
"popularity": category_data.get("popularity", 0),
"is_direct": category_data.get("isDirect", False),
"modified_on": modified_on
}.items():
setattr(existing_category, key, value)
synced_categories.append(existing_category)
else:
# Create new category
new_category = TCGPlayerCategory(
category_id=category_data["categoryId"],
name=category_data["name"],
display_name=category_data.get("displayName"),
seo_category_name=category_data.get("seoCategoryName"),
category_description=category_data.get("categoryDescription"),
category_page_title=category_data.get("categoryPageTitle"),
sealed_label=category_data.get("sealedLabel"),
non_sealed_label=category_data.get("nonSealedLabel"),
condition_guide_url=category_data.get("conditionGuideUrl"),
is_scannable=category_data.get("isScannable", False),
popularity=category_data.get("popularity", 0),
is_direct=category_data.get("isDirect", False),
modified_on=modified_on
)
db.add(new_category)
synced_categories.append(new_category)
db.commit()
return synced_categories

View File

@ -1,4 +1,4 @@
from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, Union
import aiohttp
import logging
from app.services.external_api.base_external_service import BaseExternalService
@ -7,14 +7,20 @@ from app.services.external_api.tcgplayer.tcgplayer_credentials import TCGPlayerC
logger = logging.getLogger(__name__)
class BaseTCGPlayerService(BaseExternalService):
STORE_BASE_URL = "https://store.tcgplayer.com"
LOGIN_ENDPOINT = "/oauth/login"
PRICING_ENDPOINT = "/Admin/Pricing"
def __init__(self):
super().__init__(
store_base_url="https://store.tcgplayer.com",
login_endpoint="/oauth/login",
pricing_endpoint="/Admin/Pricing",
staged_inventory_endpoint=self.pricing_endpoint + "/DownloadStagedInventoryExportCSV?type=Pricing",
live_inventory_endpoint=self.pricing_endpoint + "/DownloadMyExportCSV?type=Pricing"
)
super().__init__(base_url=self.STORE_BASE_URL)
# Set up endpoints
self.login_endpoint = self.LOGIN_ENDPOINT
self.pricing_endpoint = self.PRICING_ENDPOINT
self.staged_inventory_endpoint = f"{self.PRICING_ENDPOINT}/DownloadStagedInventoryExportCSV?type=Pricing"
self.live_inventory_endpoint = f"{self.PRICING_ENDPOINT}/DownloadMyExportCSV?type=Pricing"
self.pricing_export_endpoint = f"{self.PRICING_ENDPOINT}/downloadexportcsv"
self.credentials = TCGPlayerCredentials()
def _get_headers(self, method: str) -> Dict[str, str]:
@ -53,10 +59,11 @@ class BaseTCGPlayerService(BaseExternalService):
params: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None,
data: Optional[Dict[str, Any]] = None,
auth_required: bool = True
) -> Dict[str, Any]:
auth_required: bool = True,
download_file: bool = False
) -> Union[Dict[str, Any], bytes]:
session = await self._get_session()
url = f"{self.store_base_url}{endpoint}"
url = f"{self.base_url}{endpoint}"
# Get the authentication cookie if required
if auth_required:
@ -77,6 +84,9 @@ class BaseTCGPlayerService(BaseExternalService):
if response.status == 401:
raise RuntimeError("TCGPlayer authentication failed. Cookie may be invalid or expired.")
response.raise_for_status()
if download_file:
return await response.read()
return await response.json()
except aiohttp.ClientError as e:
logger.error(f"TCGPlayer API request failed: {str(e)}")

View File

@ -5,14 +5,17 @@ class TCGPlayerInventoryService(BaseTCGPlayerService):
def __init__(self):
super().__init__()
async def get_inventory(self) -> List[Dict]:
"""Get inventory items"""
endpoint = "/inventory"
response = await self._make_request("GET", endpoint)
return response.get("results", [])
async def update_inventory(self, updates: List[Dict]) -> Dict:
"""Update inventory items"""
endpoint = "/inventory"
response = await self._make_request("PUT", endpoint, data=updates)
return response
async def get_tcgplayer_export(self, export_type: str):
"""
Get a TCGPlayer Staged Inventory Export, Live Inventory Export, or Pricing Export
"""
if export_type == "staged":
endpoint = self.staged_inventory_endpoint
elif export_type == "live":
endpoint = self.live_inventory_endpoint
elif export_type == "pricing":
endpoint = self.pricing_export_endpoint
else:
raise ValueError(f"Invalid export type: {export_type}, must be 'staged', 'live', or 'pricing'")
file_bytes = await self._make_request("GET", endpoint, download_file=True)
return file_bytes

View File

@ -1,106 +0,0 @@
from typing import Dict, List, Optional
from datetime import datetime
from app.services.external_api.tcgplayer.base_tcgplayer_service import BaseTCGPlayerService
class TCGPlayerOrderService(BaseTCGPlayerService):
def __init__(self):
super().__init__()
async def get_orders(
self,
status: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
limit: int = 100
) -> List[Dict]:
"""
Get a list of orders with optional filtering
Args:
status: Filter by order status (e.g., "Shipped", "Processing")
start_date: Filter orders after this date
end_date: Filter orders before this date
limit: Maximum number of orders to return
Returns:
List of orders matching the criteria
"""
endpoint = "/orders"
params = {"limit": limit}
if status:
params["status"] = status
if start_date:
params["startDate"] = start_date.isoformat()
if end_date:
params["endDate"] = end_date.isoformat()
response = await self._make_request("GET", endpoint, params=params)
return response.get("results", [])
async def get_order_details(self, order_id: str) -> Dict:
"""
Get detailed information about a specific order
Args:
order_id: TCGPlayer order ID
Returns:
Detailed order information
"""
endpoint = f"/orders/{order_id}"
response = await self._make_request("GET", endpoint)
return response
async def get_order_items(self, order_id: str) -> List[Dict]:
"""
Get items in a specific order
Args:
order_id: TCGPlayer order ID
Returns:
List of items in the order
"""
endpoint = f"/orders/{order_id}/items"
response = await self._make_request("GET", endpoint)
return response.get("results", [])
async def get_order_status(self, order_id: str) -> Dict:
"""
Get the current status of an order
Args:
order_id: TCGPlayer order ID
Returns:
Order status information
"""
endpoint = f"/orders/{order_id}/status"
response = await self._make_request("GET", endpoint)
return response
async def update_order_status(
self,
order_id: str,
status: str,
tracking_number: Optional[str] = None
) -> Dict:
"""
Update the status of an order
Args:
order_id: TCGPlayer order ID
status: New status for the order
tracking_number: Optional tracking number for shipping
Returns:
Updated order information
"""
endpoint = f"/orders/{order_id}/status"
data = {"status": status}
if tracking_number:
data["trackingNumber"] = tracking_number
response = await self._make_request("PUT", endpoint, data=data)
return response

View File

@ -0,0 +1,146 @@
from typing import Optional, List, Dict
import csv
import io
import os
import json
from datetime import datetime
from sqlalchemy.orm import Session
from app.db.database import transaction
from app.models.inventory import Inventory
from app.models.tcgplayer_product import TCGPlayerProduct
from app.services.inventory_service import InventoryService
class FileProcessingService:
def __init__(self, cache_dir: str = "app/data/cache/tcgplayer"):
self.cache_dir = cache_dir
self.inventory_service = InventoryService()
os.makedirs(cache_dir, exist_ok=True)
def _get_cache_path(self, filename: str) -> str:
return os.path.join(self.cache_dir, filename)
async def _cache_export(self, file_bytes: bytes, export_type: str):
cache_path = self._get_cache_path(f"{export_type}_export.csv")
with open(cache_path, 'wb') as f:
f.write(file_bytes)
async def _load_cached_export(self, export_type: str) -> Optional[bytes]:
cache_path = self._get_cache_path(f"{export_type}_export.csv")
if os.path.exists(cache_path):
with open(cache_path, 'rb') as f:
return f.read()
return None
async def process_tcgplayer_export(self, db: Session, file_bytes: bytes, export_type: str = "live", use_cache: bool = False) -> dict:
"""
Process a TCGPlayer export file and load it into the inventory table.
Args:
db: Database session
file_bytes: The downloaded file content as bytes
export_type: Type of export (staged, live, pricing)
use_cache: Whether to use cached export file for development
Returns:
dict: Processing statistics
"""
stats = {
"total_rows": 0,
"processed_rows": 0,
"errors": 0,
"error_messages": []
}
try:
# For development, use cached file if available
if use_cache:
cached_bytes = await self._load_cached_export(export_type)
if cached_bytes:
file_bytes = cached_bytes
else:
await self._cache_export(file_bytes, export_type)
# Convert bytes to string and create a file-like object
file_content = file_bytes.decode('utf-8')
file_like = io.StringIO(file_content)
# Read CSV file
csv_reader = csv.DictReader(file_like)
with transaction(db):
for row in csv_reader:
stats["total_rows"] += 1
try:
# Process each row and create/update inventory item in database
inventory_data = self._map_tcgplayer_row_to_inventory(row)
tcgplayer_id = inventory_data["tcgplayer_id"]
# Check if inventory item already exists
existing_item = self.inventory_service.get_by_tcgplayer_id(db, tcgplayer_id)
# Find matching TCGPlayer product
product_id = int(tcgplayer_id) if tcgplayer_id.isdigit() else None
if product_id:
tcg_product = db.query(TCGPlayerProduct).filter(TCGPlayerProduct.product_id == product_id).first()
if tcg_product:
# Update inventory data with product information if available
inventory_data.update({
"product_name": tcg_product.name,
"photo_url": tcg_product.image_url,
"rarity": tcg_product.ext_rarity,
"number": tcg_product.ext_number
})
if existing_item:
# Update existing item
self.inventory_service.update(db, existing_item, inventory_data)
else:
# Create new item
self.inventory_service.create(db, inventory_data)
stats["processed_rows"] += 1
except Exception as e:
stats["errors"] += 1
stats["error_messages"].append(f"Error processing row {stats['total_rows']}: {str(e)}")
return stats
except Exception as e:
raise Exception(f"Failed to process TCGPlayer export: {str(e)}")
def _map_tcgplayer_row_to_inventory(self, row: dict) -> dict:
"""
Map TCGPlayer export row to inventory model fields.
"""
def safe_float(value: str) -> float:
"""Convert string to float, returning 0.0 for empty strings or invalid values"""
try:
return float(value) if value else 0.0
except ValueError:
return 0.0
def safe_int(value: str) -> int:
"""Convert string to int, returning 0 for empty strings or invalid values"""
try:
return int(value) if value else 0
except ValueError:
return 0
return {
"tcgplayer_id": row.get("TCGplayer Id", ""),
"product_line": row.get("Product Line", ""),
"set_name": row.get("Set Name", ""),
"product_name": row.get("Product Name", ""),
"title": row.get("Title", ""),
"number": row.get("Number", ""),
"rarity": row.get("Rarity", ""),
"condition": row.get("Condition", ""),
"tcg_market_price": safe_float(row.get("TCG Market Price", "")),
"tcg_direct_low": safe_float(row.get("TCG Direct Low", "")),
"tcg_low_price_with_shipping": safe_float(row.get("TCG Low Price With Shipping", "")),
"tcg_low_price": safe_float(row.get("TCG Low Price", "")),
"total_quantity": safe_int(row.get("Total Quantity", "")),
"add_to_quantity": safe_int(row.get("Add to Quantity", "")),
"tcg_marketplace_price": safe_float(row.get("TCG Marketplace Price", "")),
"photo_url": row.get("Photo URL", "")
}

View File

@ -0,0 +1,63 @@
from typing import List, Optional, Dict
from sqlalchemy.orm import Session
from app.models.inventory import Inventory
from app.services.base_service import BaseService
class InventoryService(BaseService[Inventory]):
def __init__(self):
super().__init__(Inventory)
def create(self, db: Session, obj_in: Dict) -> Inventory:
"""
Create a new inventory item in the database.
Args:
db: Database session
obj_in: Dictionary containing inventory data
Returns:
Inventory: The created inventory object
"""
return super().create(db, obj_in)
def update(self, db: Session, db_obj: Inventory, obj_in: Dict) -> Inventory:
"""
Update an existing inventory item in the database.
Args:
db: Database session
db_obj: The inventory object to update
obj_in: Dictionary containing updated inventory data
Returns:
Inventory: The updated inventory object
"""
return super().update(db, db_obj, obj_in)
def get_by_tcgplayer_id(self, db: Session, tcgplayer_id: str) -> Optional[Inventory]:
"""
Get an inventory item by its TCGPlayer ID.
Args:
db: Database session
tcgplayer_id: The TCGPlayer ID to find
Returns:
Optional[Inventory]: The inventory item if found, None otherwise
"""
return db.query(self.model).filter(self.model.tcgplayer_id == tcgplayer_id).first()
def get_by_set(self, db: Session, set_name: str, skip: int = 0, limit: int = 100) -> List[Inventory]:
"""
Get all inventory items from a specific set.
Args:
db: Database session
set_name: The name of the set to filter by
skip: Number of records to skip (for pagination)
limit: Maximum number of records to return
Returns:
List[Inventory]: List of inventory items from the specified set
"""
return db.query(self.model).filter(self.model.set_name == set_name).offset(skip).limit(limit).all()

View File

@ -0,0 +1,58 @@
from sqlalchemy.orm import Session
from app.services.base_service import BaseService
from app.models.order import Order, OrderCard
from app.models.card import Card
class OrderService(BaseService):
def __init__(self):
super().__init__(Order)
def create_order_with_cards(self, db: Session, order_data: dict, card_ids: list[int]) -> Order:
"""
Create a new order with associated cards.
Args:
db: Database session
order_data: Dictionary containing order details
card_ids: List of card IDs to associate with the order
Returns:
The created Order object
"""
# Create the order
order = Order(**order_data)
db.add(order)
db.flush() # Get the order ID
# Associate cards with the order
for card_id in card_ids:
card = db.query(Card).filter(Card.id == card_id).first()
if not card:
raise ValueError(f"Card with ID {card_id} not found")
order_card = OrderCard(order_id=order.id, card_id=card_id)
db.add(order_card)
db.commit()
db.refresh(order)
return order
def get_orders_with_cards(self, db: Session, skip: int = 0, limit: int = 10) -> list[Order]:
"""
Get orders with their associated cards.
Args:
db: Database session
skip: Number of records to skip
limit: Maximum number of records to return
Returns:
List of Order objects with their associated cards
"""
orders = db.query(Order).offset(skip).limit(limit).all()
# Eager load the cards for each order
for order in orders:
order.cards = db.query(Card).join(OrderCard).filter(OrderCard.order_id == order.id).all()
return orders

View File

@ -0,0 +1,54 @@
from sqlalchemy.orm import Session
from app.db.database import SessionLocal, transaction
from app.services.external_api.tcgplayer.tcgplayer_inventory_service import TCGPlayerInventoryService
from app.services.file_processing_service import FileProcessingService
from app.services.scheduler.base_scheduler import BaseScheduler
import logging
logger = logging.getLogger(__name__)
class SchedulerService:
def __init__(self):
self.tcgplayer_service = TCGPlayerInventoryService()
self.file_processor = FileProcessingService()
self.scheduler = BaseScheduler()
async def process_tcgplayer_export(self, export_type: str = "live", use_cache: bool = False):
"""
Process TCGPlayer export as a scheduled task.
Args:
export_type: Type of export to process (staged, live, or pricing)
"""
db = SessionLocal()
try:
logger.info(f"Starting scheduled TCGPlayer export processing for {export_type}")
# Download the file
file_bytes = await self.tcgplayer_service.get_tcgplayer_export(export_type)
# Process the file and load into database
with transaction(db):
stats = await self.file_processor.process_tcgplayer_export(db, export_type=export_type, file_bytes=file_bytes, use_cache=use_cache)
logger.info(f"Completed TCGPlayer export processing: {stats}")
return stats
except Exception as e:
logger.error(f"Error processing TCGPlayer export: {str(e)}")
raise
finally:
db.close()
async def start_scheduled_tasks(self):
"""Start all scheduled tasks"""
# Schedule TCGPlayer export processing to run daily at 2 AM
await self.scheduler.schedule_task(
task_name="process_tcgplayer_export",
func=self.process_tcgplayer_export,
interval_seconds=24 * 60 * 60, # 24 hours
export_type="live"
)
self.scheduler.start()
logger.info("All scheduled tasks started")

34
requirements.txt Normal file
View File

@ -0,0 +1,34 @@
aiohappyeyeballs==2.6.1
aiohttp==3.11.16
aiosignal==1.3.2
alembic==1.13.1
annotated-types==0.7.0
anyio==4.9.0
APScheduler==3.10.4
attrs==25.3.0
blinker==1.9.0
click==8.1.8
fastapi==0.115.12
Flask==3.1.0
frozenlist==1.5.0
h11==0.14.0
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.6
Mako==1.3.9
MarkupSafe==3.0.2
multidict==6.4.2
propcache==0.3.1
pydantic==2.11.3
pydantic_core==2.33.1
pytz==2025.2
six==1.17.0
sniffio==1.3.1
SQLAlchemy==2.0.40
starlette==0.46.1
typing-inspection==0.4.0
typing_extensions==4.13.1
tzlocal==5.3.1
uvicorn==0.34.0
Werkzeug==3.1.3
yarl==1.19.0