From df6490cab00e1972f39f55248d2336b03e0621a2 Mon Sep 17 00:00:00 2001 From: zman Date: Wed, 9 Apr 2025 23:53:05 -0400 Subject: [PATCH] so much stuff lol --- .gitignore | 2 + alembic.ini | 114 ++++++++++ alembic/README | 1 + alembic/env.py | 84 +++++++ alembic/script.py.mako | 26 +++ ...04_09_create_tcgplayer_categories_table.py | 49 +++++ .../4dbeb89dd33a_create_inventory_table.py | 171 +++++++++++++++ .../create_tcgplayer_categories_table.py | 49 +++++ .../remove_product_id_unique_constraint.py | 101 +++++++++ app.log | 20 ++ app/db/database.py | 2 + app/main.py | 83 +++++-- app/models/box.py | 9 +- app/models/card.py | 24 +- app/models/file.py | 5 +- app/models/game.py | 6 - app/models/inventory.py | 28 +++ app/models/order.py | 25 +++ app/models/tcgplayer_category.py | 23 ++ app/models/tcgplayer_group.py | 17 ++ app/models/tcgplayer_product.py | 27 +++ app/routes/routes.py | 61 ++++-- app/schemas/box.py | 65 +++--- app/schemas/card.py | 52 +++-- app/schemas/file.py | 26 ++- app/schemas/game.py | 30 +-- app/services/__init__.py | 12 +- app/services/card_service.py | 117 +++++++++- app/services/data_initialization.py | 201 +++++++++++++++++ .../external_api/base_external_service.py | 33 ++- .../external_api/tcgcsv/tcgcsv_service.py | 205 ++++++++++++++++++ .../tcgplayer/base_tcgplayer_service.py | 32 ++- .../tcgplayer/tcgplayer_inventory_service.py | 25 ++- .../tcgplayer/tcgplayer_order_service.py | 106 --------- .../tcgplayer/tcgplayer_pricing_service.py | 0 app/services/file_processing_service.py | 146 +++++++++++++ app/services/inventory_service.py | 63 ++++++ app/services/order_service.py | 58 +++++ app/services/scheduler/scheduler_service.py | 54 +++++ requirements.txt | 34 +++ 40 files changed, 1909 insertions(+), 277 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/2025_04_09_create_tcgplayer_categories_table.py create mode 100644 alembic/versions/4dbeb89dd33a_create_inventory_table.py create mode 100644 alembic/versions/create_tcgplayer_categories_table.py create mode 100644 alembic/versions/remove_product_id_unique_constraint.py create mode 100644 app.log create mode 100644 app/models/inventory.py create mode 100644 app/models/order.py create mode 100644 app/models/tcgplayer_category.py create mode 100644 app/models/tcgplayer_group.py create mode 100644 app/models/tcgplayer_product.py create mode 100644 app/services/data_initialization.py create mode 100644 app/services/external_api/tcgcsv/tcgcsv_service.py delete mode 100644 app/services/external_api/tcgplayer/tcgplayer_order_service.py delete mode 100644 app/services/external_api/tcgplayer/tcgplayer_pricing_service.py create mode 100644 app/services/file_processing_service.py create mode 100644 app/services/inventory_service.py create mode 100644 app/services/order_service.py create mode 100644 app/services/scheduler/scheduler_service.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index af5f038..36b9c5b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ __pycache__ _cookie.py + +app/data/cache/* \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..f175362 --- /dev/null +++ b/alembic.ini @@ -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 diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..918f007 --- /dev/null +++ b/alembic/env.py @@ -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() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -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"} diff --git a/alembic/versions/2025_04_09_create_tcgplayer_categories_table.py b/alembic/versions/2025_04_09_create_tcgplayer_categories_table.py new file mode 100644 index 0000000..8cb9369 --- /dev/null +++ b/alembic/versions/2025_04_09_create_tcgplayer_categories_table.py @@ -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') \ No newline at end of file diff --git a/alembic/versions/4dbeb89dd33a_create_inventory_table.py b/alembic/versions/4dbeb89dd33a_create_inventory_table.py new file mode 100644 index 0000000..3f7a5e7 --- /dev/null +++ b/alembic/versions/4dbeb89dd33a_create_inventory_table.py @@ -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 ### diff --git a/alembic/versions/create_tcgplayer_categories_table.py b/alembic/versions/create_tcgplayer_categories_table.py new file mode 100644 index 0000000..a900949 --- /dev/null +++ b/alembic/versions/create_tcgplayer_categories_table.py @@ -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') \ No newline at end of file diff --git a/alembic/versions/remove_product_id_unique_constraint.py b/alembic/versions/remove_product_id_unique_constraint.py new file mode 100644 index 0000000..9b12a01 --- /dev/null +++ b/alembic/versions/remove_product_id_unique_constraint.py @@ -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) \ No newline at end of file diff --git a/app.log b/app.log new file mode 100644 index 0000000..da3310f --- /dev/null +++ b/app.log @@ -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 diff --git a/app/db/database.py b/app/db/database.py index 19a6744..236dc96 100644 --- a/app/db/database.py +++ b/app/db/database.py @@ -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(): diff --git a/app/main.py b/app/main.py index f38f8d6..fb0f623 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/models/box.py b/app/models/box.py index 1a6afa2..a9815fb 100644 --- a/app/models/box.py +++ b/app/models/box.py @@ -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") diff --git a/app/models/card.py b/app/models/card.py index ce379c5..96936bc 100644 --- a/app/models/card.py +++ b/app/models/card.py @@ -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") diff --git a/app/models/file.py b/app/models/file.py index 87c094f..697a838 100644 --- a/app/models/file.py +++ b/app/models/file.py @@ -15,7 +15,4 @@ class File(Base): type = Column(String) path = Column(String) 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()) \ No newline at end of file diff --git a/app/models/game.py b/app/models/game.py index b757a57..0169928 100644 --- a/app/models/game.py +++ b/app/models/game.py @@ -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") diff --git a/app/models/inventory.py b/app/models/inventory.py new file mode 100644 index 0000000..b2264ff --- /dev/null +++ b/app/models/inventory.py @@ -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()) \ No newline at end of file diff --git a/app/models/order.py b/app/models/order.py new file mode 100644 index 0000000..5369437 --- /dev/null +++ b/app/models/order.py @@ -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) \ No newline at end of file diff --git a/app/models/tcgplayer_category.py b/app/models/tcgplayer_category.py new file mode 100644 index 0000000..3963920 --- /dev/null +++ b/app/models/tcgplayer_category.py @@ -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()) \ No newline at end of file diff --git a/app/models/tcgplayer_group.py b/app/models/tcgplayer_group.py new file mode 100644 index 0000000..9e6eb00 --- /dev/null +++ b/app/models/tcgplayer_group.py @@ -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()) \ No newline at end of file diff --git a/app/models/tcgplayer_product.py b/app/models/tcgplayer_product.py new file mode 100644 index 0000000..110bb4b --- /dev/null +++ b/app/models/tcgplayer_product.py @@ -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()) \ No newline at end of file diff --git a/app/routes/routes.py b/app/routes/routes.py index 2400554..8a09518 100644 --- a/app/routes/routes.py +++ b/app/routes/routes.py @@ -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)) + diff --git a/app/schemas/box.py b/app/schemas/box.py index e8701ae..eb27ba6 100644 --- a/app/schemas/box.py +++ b/app/schemas/box.py @@ -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 \ No newline at end of file diff --git a/app/schemas/card.py b/app/schemas/card.py index b120155..3d4109c 100644 --- a/app/schemas/card.py +++ b/app/schemas/card.py @@ -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 \ No newline at end of file + limit: int + +# Schema for deleting a card +class CardDelete(BaseModel): + message: str \ No newline at end of file diff --git a/app/schemas/file.py b/app/schemas/file.py index 671373a..39965b1 100644 --- a/app/schemas/file.py +++ b/app/schemas/file.py @@ -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 \ No newline at end of file diff --git a/app/schemas/game.py b/app/schemas/game.py index 4ffc0cc..69c23ac 100644 --- a/app/schemas/game.py +++ b/app/schemas/game.py @@ -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 \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py index 3b5ea14..d99ec72 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -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"] \ No newline at end of file +__all__ = [ + 'BaseService', + 'CardService', + 'OrderService', + 'FileProcessingService', + 'InventoryService', + 'ServiceRegistry' +] \ No newline at end of file diff --git a/app/services/card_service.py b/app/services/card_service.py index 874b73e..e7c31d2 100644 --- a/app/services/card_service.py +++ b/app/services/card_service.py @@ -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() \ No newline at end of file + 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() \ No newline at end of file diff --git a/app/services/data_initialization.py b/app/services/data_initialization.py new file mode 100644 index 0000000..3101836 --- /dev/null +++ b/app/services/data_initialization.py @@ -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() \ No newline at end of file diff --git a/app/services/external_api/base_external_service.py b/app/services/external_api/base_external_service.py index 572a1ff..3323b71 100644 --- a/app/services/external_api/base_external_service.py +++ b/app/services/external_api/base_external_service.py @@ -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)}") diff --git a/app/services/external_api/tcgcsv/tcgcsv_service.py b/app/services/external_api/tcgcsv/tcgcsv_service.py new file mode 100644 index 0000000..db87adf --- /dev/null +++ b/app/services/external_api/tcgcsv/tcgcsv_service.py @@ -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 diff --git a/app/services/external_api/tcgplayer/base_tcgplayer_service.py b/app/services/external_api/tcgplayer/base_tcgplayer_service.py index 8986429..4d97a98 100644 --- a/app/services/external_api/tcgplayer/base_tcgplayer_service.py +++ b/app/services/external_api/tcgplayer/base_tcgplayer_service.py @@ -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)}") diff --git a/app/services/external_api/tcgplayer/tcgplayer_inventory_service.py b/app/services/external_api/tcgplayer/tcgplayer_inventory_service.py index 64e4bc4..2b24b04 100644 --- a/app/services/external_api/tcgplayer/tcgplayer_inventory_service.py +++ b/app/services/external_api/tcgplayer/tcgplayer_inventory_service.py @@ -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 \ No newline at end of file diff --git a/app/services/external_api/tcgplayer/tcgplayer_order_service.py b/app/services/external_api/tcgplayer/tcgplayer_order_service.py deleted file mode 100644 index e018c97..0000000 --- a/app/services/external_api/tcgplayer/tcgplayer_order_service.py +++ /dev/null @@ -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 diff --git a/app/services/external_api/tcgplayer/tcgplayer_pricing_service.py b/app/services/external_api/tcgplayer/tcgplayer_pricing_service.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/services/file_processing_service.py b/app/services/file_processing_service.py new file mode 100644 index 0000000..5f76498 --- /dev/null +++ b/app/services/file_processing_service.py @@ -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", "") + } \ No newline at end of file diff --git a/app/services/inventory_service.py b/app/services/inventory_service.py new file mode 100644 index 0000000..32eb856 --- /dev/null +++ b/app/services/inventory_service.py @@ -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() \ No newline at end of file diff --git a/app/services/order_service.py b/app/services/order_service.py new file mode 100644 index 0000000..de9f477 --- /dev/null +++ b/app/services/order_service.py @@ -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 \ No newline at end of file diff --git a/app/services/scheduler/scheduler_service.py b/app/services/scheduler/scheduler_service.py new file mode 100644 index 0000000..187e0d5 --- /dev/null +++ b/app/services/scheduler/scheduler_service.py @@ -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") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ded6e20 --- /dev/null +++ b/requirements.txt @@ -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