so much stuff lol
This commit is contained in:
parent
1c00ea8569
commit
df6490cab0
2
.gitignore
vendored
2
.gitignore
vendored
@ -8,3 +8,5 @@
|
|||||||
|
|
||||||
__pycache__
|
__pycache__
|
||||||
_cookie.py
|
_cookie.py
|
||||||
|
|
||||||
|
app/data/cache/*
|
114
alembic.ini
Normal file
114
alembic.ini
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts
|
||||||
|
script_location = alembic
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the python-dateutil library that can be
|
||||||
|
# installed by adding `alembic[tz]` to the pip requirements
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the
|
||||||
|
# "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to alembic/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||||
|
|
||||||
|
# version path separator; As mentioned above, this is the character used to split
|
||||||
|
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||||
|
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or colons.
|
||||||
|
# Valid values for version_path_separator are:
|
||||||
|
#
|
||||||
|
# version_path_separator = :
|
||||||
|
# version_path_separator = ;
|
||||||
|
# version_path_separator = space
|
||||||
|
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
sqlalchemy.url = sqlite:///database.db
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = %(here)s/ruff
|
||||||
|
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
1
alembic/README
Normal file
1
alembic/README
Normal file
@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
84
alembic/env.py
Normal file
84
alembic/env.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config
|
||||||
|
from sqlalchemy import pool
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# Import your models here
|
||||||
|
from app.db.database import Base
|
||||||
|
from app.models.inventory import Inventory
|
||||||
|
from app.models.card import Card
|
||||||
|
from app.models.box import Box, OpenBox
|
||||||
|
from app.models.game import Game
|
||||||
|
from app.models.file import File
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection, target_metadata=target_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
26
alembic/script.py.mako
Normal file
26
alembic/script.py.mako
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
@ -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')
|
171
alembic/versions/4dbeb89dd33a_create_inventory_table.py
Normal file
171
alembic/versions/4dbeb89dd33a_create_inventory_table.py
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
"""create inventory table
|
||||||
|
|
||||||
|
Revision ID: 4dbeb89dd33a
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-04-09 21:56:49.068087
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '4dbeb89dd33a'
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('boxes',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('product_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('type', sa.String(), nullable=True),
|
||||||
|
sa.Column('set_code', sa.String(), nullable=True),
|
||||||
|
sa.Column('sku', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('name', sa.String(), nullable=True),
|
||||||
|
sa.Column('expected_number_of_cards', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('description', sa.String(), nullable=True),
|
||||||
|
sa.Column('image_url', sa.String(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_boxes_id'), 'boxes', ['id'], unique=False)
|
||||||
|
op.create_table('cards',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(), nullable=True),
|
||||||
|
sa.Column('rarity', sa.String(), nullable=True),
|
||||||
|
sa.Column('set_name', sa.String(), nullable=True),
|
||||||
|
sa.Column('price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('quantity', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('tcgplayer_sku', sa.String(), nullable=True),
|
||||||
|
sa.Column('product_line', sa.String(), nullable=True),
|
||||||
|
sa.Column('product_name', sa.String(), nullable=True),
|
||||||
|
sa.Column('title', sa.String(), nullable=True),
|
||||||
|
sa.Column('number', sa.String(), nullable=True),
|
||||||
|
sa.Column('condition', sa.String(), nullable=True),
|
||||||
|
sa.Column('tcg_market_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('tcg_direct_low', sa.Float(), nullable=True),
|
||||||
|
sa.Column('tcg_low_price_with_shipping', sa.Float(), nullable=True),
|
||||||
|
sa.Column('tcg_low_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('total_quantity', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('add_to_quantity', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('tcg_marketplace_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('photo_url', sa.String(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_cards_id'), 'cards', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_cards_name'), 'cards', ['name'], unique=False)
|
||||||
|
op.create_index(op.f('ix_cards_set_name'), 'cards', ['set_name'], unique=False)
|
||||||
|
op.create_index(op.f('ix_cards_tcgplayer_sku'), 'cards', ['tcgplayer_sku'], unique=True)
|
||||||
|
op.create_table('files',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(), nullable=True),
|
||||||
|
sa.Column('type', sa.String(), nullable=True),
|
||||||
|
sa.Column('path', sa.String(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_files_id'), 'files', ['id'], unique=False)
|
||||||
|
op.create_table('games',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(), nullable=True),
|
||||||
|
sa.Column('description', sa.String(), nullable=True),
|
||||||
|
sa.Column('image_url', sa.String(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_games_id'), 'games', ['id'], unique=False)
|
||||||
|
op.create_table('inventory',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('tcgplayer_id', sa.String(), nullable=True),
|
||||||
|
sa.Column('product_line', sa.String(), nullable=True),
|
||||||
|
sa.Column('set_name', sa.String(), nullable=True),
|
||||||
|
sa.Column('product_name', sa.String(), nullable=True),
|
||||||
|
sa.Column('title', sa.String(), nullable=True),
|
||||||
|
sa.Column('number', sa.String(), nullable=True),
|
||||||
|
sa.Column('rarity', sa.String(), nullable=True),
|
||||||
|
sa.Column('condition', sa.String(), nullable=True),
|
||||||
|
sa.Column('tcg_market_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('tcg_direct_low', sa.Float(), nullable=True),
|
||||||
|
sa.Column('tcg_low_price_with_shipping', sa.Float(), nullable=True),
|
||||||
|
sa.Column('tcg_low_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('total_quantity', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('add_to_quantity', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('tcg_marketplace_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('photo_url', sa.String(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_inventory_id'), 'inventory', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_inventory_tcgplayer_id'), 'inventory', ['tcgplayer_id'], unique=True)
|
||||||
|
op.create_table('open_boxes',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('box_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('number_of_cards', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('date_opened', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['box_id'], ['boxes.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_open_boxes_id'), 'open_boxes', ['id'], unique=False)
|
||||||
|
op.create_table('tcgplayer_products',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('product_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(), nullable=False),
|
||||||
|
sa.Column('clean_name', sa.String(), nullable=True),
|
||||||
|
sa.Column('image_url', sa.String(), nullable=True),
|
||||||
|
sa.Column('category_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('group_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('url', sa.String(), nullable=True),
|
||||||
|
sa.Column('modified_on', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('image_count', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('ext_rarity', sa.String(), nullable=True),
|
||||||
|
sa.Column('ext_number', sa.String(), nullable=True),
|
||||||
|
sa.Column('low_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('mid_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('high_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('market_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('direct_low_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('sub_type_name', sa.String(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('product_id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_tcgplayer_products_id'), 'tcgplayer_products', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_tcgplayer_products_product_id'), 'tcgplayer_products', ['product_id'], unique=True)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_tcgplayer_products_product_id'), table_name='tcgplayer_products')
|
||||||
|
op.drop_index(op.f('ix_tcgplayer_products_id'), table_name='tcgplayer_products')
|
||||||
|
op.drop_index(op.f('ix_open_boxes_id'), table_name='open_boxes')
|
||||||
|
op.drop_table('open_boxes')
|
||||||
|
op.drop_index(op.f('ix_inventory_tcgplayer_id'), table_name='inventory')
|
||||||
|
op.drop_index(op.f('ix_inventory_id'), table_name='inventory')
|
||||||
|
op.drop_table('inventory')
|
||||||
|
op.drop_index(op.f('ix_games_id'), table_name='games')
|
||||||
|
op.drop_table('games')
|
||||||
|
op.drop_index(op.f('ix_files_id'), table_name='files')
|
||||||
|
op.drop_table('files')
|
||||||
|
op.drop_index(op.f('ix_cards_tcgplayer_sku'), table_name='cards')
|
||||||
|
op.drop_index(op.f('ix_cards_set_name'), table_name='cards')
|
||||||
|
op.drop_index(op.f('ix_cards_name'), table_name='cards')
|
||||||
|
op.drop_index(op.f('ix_cards_id'), table_name='cards')
|
||||||
|
op.drop_table('cards')
|
||||||
|
op.drop_index(op.f('ix_boxes_id'), table_name='boxes')
|
||||||
|
op.drop_table('boxes')
|
||||||
|
# ### end Alembic commands ###
|
49
alembic/versions/create_tcgplayer_categories_table.py
Normal file
49
alembic/versions/create_tcgplayer_categories_table.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
"""create tcgplayer categories table
|
||||||
|
|
||||||
|
Revision ID: create_tcgplayer_categories_table
|
||||||
|
Revises: remove_product_id_unique_constraint
|
||||||
|
Create Date: 2025-04-09 23:20:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'create_tcgplayer_categories_table'
|
||||||
|
down_revision: str = 'remove_product_id_unique_constraint'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table('tcgplayer_categories',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('category_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(), nullable=False),
|
||||||
|
sa.Column('display_name', sa.String(), nullable=True),
|
||||||
|
sa.Column('seo_category_name', sa.String(), nullable=True),
|
||||||
|
sa.Column('category_description', sa.String(), nullable=True),
|
||||||
|
sa.Column('category_page_title', sa.String(), nullable=True),
|
||||||
|
sa.Column('sealed_label', sa.String(), nullable=True),
|
||||||
|
sa.Column('non_sealed_label', sa.String(), nullable=True),
|
||||||
|
sa.Column('condition_guide_url', sa.String(), nullable=True),
|
||||||
|
sa.Column('is_scannable', sa.Boolean(), nullable=True, default=False),
|
||||||
|
sa.Column('popularity', sa.Integer(), nullable=True, default=0),
|
||||||
|
sa.Column('is_direct', sa.Boolean(), nullable=True, default=False),
|
||||||
|
sa.Column('modified_on', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('category_id')
|
||||||
|
)
|
||||||
|
op.create_index('ix_tcgplayer_categories_id', 'tcgplayer_categories', ['id'], unique=False)
|
||||||
|
op.create_index('ix_tcgplayer_categories_category_id', 'tcgplayer_categories', ['category_id'], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_tcgplayer_categories_category_id', table_name='tcgplayer_categories')
|
||||||
|
op.drop_index('ix_tcgplayer_categories_id', table_name='tcgplayer_categories')
|
||||||
|
op.drop_table('tcgplayer_categories')
|
101
alembic/versions/remove_product_id_unique_constraint.py
Normal file
101
alembic/versions/remove_product_id_unique_constraint.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
"""remove product_id unique constraint
|
||||||
|
|
||||||
|
Revision ID: remove_product_id_unique_constraint
|
||||||
|
Revises: 4dbeb89dd33a
|
||||||
|
Create Date: 2025-04-09 23:10:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'remove_product_id_unique_constraint'
|
||||||
|
down_revision: str = '4dbeb89dd33a'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create a new table without the unique constraint
|
||||||
|
op.create_table('tcgplayer_products_new',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('product_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('name', sa.String(), nullable=False),
|
||||||
|
sa.Column('clean_name', sa.String(), nullable=True),
|
||||||
|
sa.Column('image_url', sa.String(), nullable=True),
|
||||||
|
sa.Column('category_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('group_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('url', sa.String(), nullable=True),
|
||||||
|
sa.Column('modified_on', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('image_count', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('ext_rarity', sa.String(), nullable=True),
|
||||||
|
sa.Column('ext_number', sa.String(), nullable=True),
|
||||||
|
sa.Column('low_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('mid_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('high_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('market_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('direct_low_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('sub_type_name', sa.String(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.ForeignKeyConstraint(['group_id'], ['tcgplayer_groups.group_id'])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy data from old table to new table
|
||||||
|
op.execute('INSERT INTO tcgplayer_products_new SELECT * FROM tcgplayer_products')
|
||||||
|
|
||||||
|
# Drop old table
|
||||||
|
op.drop_table('tcgplayer_products')
|
||||||
|
|
||||||
|
# Rename new table to old table name
|
||||||
|
op.rename_table('tcgplayer_products_new', 'tcgplayer_products')
|
||||||
|
|
||||||
|
# Create indexes
|
||||||
|
op.create_index('ix_tcgplayer_products_id', 'tcgplayer_products', ['id'], unique=False)
|
||||||
|
op.create_index('ix_tcgplayer_products_product_id', 'tcgplayer_products', ['product_id'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Create a new table with the unique constraint
|
||||||
|
op.create_table('tcgplayer_products_new',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('product_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('name', sa.String(), nullable=False),
|
||||||
|
sa.Column('clean_name', sa.String(), nullable=True),
|
||||||
|
sa.Column('image_url', sa.String(), nullable=True),
|
||||||
|
sa.Column('category_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('group_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('url', sa.String(), nullable=True),
|
||||||
|
sa.Column('modified_on', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('image_count', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('ext_rarity', sa.String(), nullable=True),
|
||||||
|
sa.Column('ext_number', sa.String(), nullable=True),
|
||||||
|
sa.Column('low_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('mid_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('high_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('market_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('direct_low_price', sa.Float(), nullable=True),
|
||||||
|
sa.Column('sub_type_name', sa.String(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('product_id'),
|
||||||
|
sa.ForeignKeyConstraint(['group_id'], ['tcgplayer_groups.group_id'])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy data from old table to new table
|
||||||
|
op.execute('INSERT INTO tcgplayer_products_new SELECT * FROM tcgplayer_products')
|
||||||
|
|
||||||
|
# Drop old table
|
||||||
|
op.drop_table('tcgplayer_products')
|
||||||
|
|
||||||
|
# Rename new table to old table name
|
||||||
|
op.rename_table('tcgplayer_products_new', 'tcgplayer_products')
|
||||||
|
|
||||||
|
# Create indexes
|
||||||
|
op.create_index('ix_tcgplayer_products_id', 'tcgplayer_products', ['id'], unique=False)
|
||||||
|
op.create_index('ix_tcgplayer_products_product_id', 'tcgplayer_products', ['product_id'], unique=True)
|
20
app.log
Normal file
20
app.log
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
2025-04-09 23:51:18,179 - INFO - app.main - Application starting up...
|
||||||
|
2025-04-09 23:51:18,188 - INFO - app.main - Database initialized successfully
|
||||||
|
2025-04-09 23:51:22,506 - INFO - app.services.external_api.base_external_service - Making request to https://tcgcsv.com/tcgplayer/1/23167/ProductsAndPrices.csv
|
||||||
|
2025-04-09 23:51:23,897 - INFO - app.services.external_api.base_external_service - Making request to https://tcgcsv.com/tcgplayer/1/3100/ProductsAndPrices.csv
|
||||||
|
2025-04-09 23:51:29,067 - INFO - app.services.external_api.base_external_service - Making request to https://tcgcsv.com/tcgplayer/1/2572/ProductsAndPrices.csv
|
||||||
|
2025-04-09 23:51:37,721 - INFO - app.services.external_api.base_external_service - Making request to https://tcgcsv.com/tcgplayer/1/14/ProductsAndPrices.csv
|
||||||
|
2025-04-09 23:51:38,716 - INFO - app.services.external_api.base_external_service - Making request to https://tcgcsv.com/tcgplayer/1/29/ProductsAndPrices.csv
|
||||||
|
2025-04-09 23:51:38,737 - INFO - app.services.external_api.base_external_service - Making request to https://tcgcsv.com/tcgplayer/1/64/ProductsAndPrices.csv
|
||||||
|
2025-04-09 23:51:44,117 - INFO - app.services.external_api.base_external_service - Making request to https://tcgcsv.com/tcgplayer/3/609/ProductsAndPrices.csv
|
||||||
|
2025-04-09 23:51:44,140 - INFO - app.services.external_api.base_external_service - Making request to https://tcgcsv.com/tcgplayer/3/610/ProductsAndPrices.csv
|
||||||
|
2025-04-09 23:51:45,264 - INFO - app.main - TCGPlayer data initialized successfully
|
||||||
|
2025-04-09 23:51:45,265 - INFO - apscheduler.scheduler - Adding job tentatively -- it will be properly scheduled when the scheduler starts
|
||||||
|
2025-04-09 23:51:45,265 - INFO - app.services.scheduler.base_scheduler - Scheduled task process_tcgplayer_export to run every 86400 seconds
|
||||||
|
2025-04-09 23:51:45,265 - INFO - apscheduler.scheduler - Added job "SchedulerService.process_tcgplayer_export" to job store "default"
|
||||||
|
2025-04-09 23:51:45,265 - INFO - apscheduler.scheduler - Scheduler started
|
||||||
|
2025-04-09 23:51:45,265 - INFO - app.services.scheduler.base_scheduler - Scheduler started
|
||||||
|
2025-04-09 23:51:45,265 - INFO - app.services.scheduler.scheduler_service - All scheduled tasks started
|
||||||
|
2025-04-09 23:51:45,266 - INFO - app.services.scheduler.scheduler_service - Starting scheduled TCGPlayer export processing for live
|
||||||
|
2025-04-09 23:51:51,510 - INFO - app.services.scheduler.scheduler_service - Completed TCGPlayer export processing: {'total_rows': 5793, 'processed_rows': 5793, 'errors': 0, 'error_messages': []}
|
||||||
|
2025-04-09 23:51:51,510 - INFO - app.main - Scheduler started successfully
|
@ -34,6 +34,8 @@ def transaction(db: Session):
|
|||||||
db.commit()
|
db.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
# Reset the session state
|
||||||
|
db.expire_all()
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
|
83
app/main.py
83
app/main.py
@ -1,22 +1,77 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import logging
|
import logging
|
||||||
from routes import routes
|
import os
|
||||||
from db.database import init_db
|
from app.routes import routes
|
||||||
from app.models.card import Card # Assuming you have a Card model
|
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(
|
# Configure logging
|
||||||
level=logging.INFO,
|
log_file = "app.log"
|
||||||
format="%(asctime)s - %(levelname)s - %(message)s",
|
if os.path.exists(log_file):
|
||||||
handlers=[logging.StreamHandler(), logging.FileHandler("app.log")],)
|
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 = 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(
|
app = FastAPI(
|
||||||
title="CCR Cards Management API",
|
title="CCR Cards Management API",
|
||||||
description="API for managing CCR Cards Inventory, Orders, and more.",
|
description="API for managing CCR Cards Inventory, Orders, and more.",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
|
lifespan=lifespan
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@ -29,17 +84,5 @@ app.add_middleware(
|
|||||||
|
|
||||||
app.include_router(routes.router)
|
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__":
|
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)
|
||||||
|
@ -12,16 +12,13 @@ class Box(Base):
|
|||||||
set_code = Column(String)
|
set_code = Column(String)
|
||||||
sku = Column(Integer)
|
sku = Column(Integer)
|
||||||
name = Column(String)
|
name = Column(String)
|
||||||
|
game_id = Column(Integer, ForeignKey("games.id"))
|
||||||
expected_number_of_cards = Column(Integer)
|
expected_number_of_cards = Column(Integer)
|
||||||
description = Column(String)
|
description = Column(String)
|
||||||
image_url = Column(String)
|
image_url = Column(String)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=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):
|
class OpenBox(Base):
|
||||||
__tablename__ = "open_boxes"
|
__tablename__ = "open_boxes"
|
||||||
|
|
||||||
@ -31,7 +28,3 @@ class OpenBox(Base):
|
|||||||
date_opened = Column(DateTime(timezone=True))
|
date_opened = Column(DateTime(timezone=True))
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=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")
|
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy import Column, Integer, String, Float, ForeignKey, 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)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
name = Column(String, index=True)
|
name = Column(String, index=True)
|
||||||
rarity = Column(String)
|
rarity = Column(String)
|
||||||
set_name = Column(String)
|
set_name = Column(String, index=True)
|
||||||
price = Column(Float)
|
price = Column(Float)
|
||||||
quantity = Column(Integer, default=0)
|
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())
|
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())
|
||||||
|
|
||||||
# Relationships
|
|
||||||
orders = relationship("Order", secondary="order_cards", back_populates="cards")
|
|
||||||
|
@ -16,6 +16,3 @@ class File(Base):
|
|||||||
path = Column(String)
|
path = Column(String)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -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 import Column, Integer, String, DateTime
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
@ -15,6 +12,3 @@ class Game(Base):
|
|||||||
image_url = Column(String)
|
image_url = Column(String)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
# Relationships
|
|
||||||
boxes = relationship("Box", back_populates="game")
|
|
||||||
|
28
app/models/inventory.py
Normal file
28
app/models/inventory.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Float, DateTime
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.db.database import Base
|
||||||
|
|
||||||
|
class Inventory(Base):
|
||||||
|
__tablename__ = "inventory"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
tcgplayer_id = Column(String, unique=True, index=True)
|
||||||
|
product_line = Column(String)
|
||||||
|
set_name = Column(String)
|
||||||
|
product_name = Column(String)
|
||||||
|
title = Column(String)
|
||||||
|
number = Column(String)
|
||||||
|
rarity = Column(String)
|
||||||
|
condition = Column(String)
|
||||||
|
tcg_market_price = Column(Float)
|
||||||
|
tcg_direct_low = Column(Float)
|
||||||
|
tcg_low_price_with_shipping = Column(Float)
|
||||||
|
tcg_low_price = Column(Float)
|
||||||
|
total_quantity = Column(Integer)
|
||||||
|
add_to_quantity = Column(Integer)
|
||||||
|
tcg_marketplace_price = Column(Float)
|
||||||
|
photo_url = Column(String)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
25
app/models/order.py
Normal file
25
app/models/order.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
from app.db.database import Base
|
||||||
|
|
||||||
|
class Order(Base):
|
||||||
|
__tablename__ = "orders"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
customer_name = Column(String, index=True)
|
||||||
|
customer_email = Column(String)
|
||||||
|
total_amount = Column(Float)
|
||||||
|
status = Column(String, default="pending") # pending, processing, shipped, delivered, cancelled
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
class OrderCard(Base):
|
||||||
|
__tablename__ = "order_cards"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
order_id = Column(Integer, ForeignKey("orders.id"))
|
||||||
|
card_id = Column(Integer, ForeignKey("cards.id"))
|
||||||
|
quantity = Column(Integer, default=1)
|
||||||
|
price_at_time = Column(Float) # Price of the card when ordered
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
23
app/models/tcgplayer_category.py
Normal file
23
app/models/tcgplayer_category.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.db.database import Base
|
||||||
|
|
||||||
|
class TCGPlayerCategory(Base):
|
||||||
|
__tablename__ = "tcgplayer_categories"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
category_id = Column(Integer, unique=True, index=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
display_name = Column(String)
|
||||||
|
seo_category_name = Column(String)
|
||||||
|
category_description = Column(String)
|
||||||
|
category_page_title = Column(String)
|
||||||
|
sealed_label = Column(String)
|
||||||
|
non_sealed_label = Column(String)
|
||||||
|
condition_guide_url = Column(String)
|
||||||
|
is_scannable = Column(Boolean, default=False)
|
||||||
|
popularity = Column(Integer, default=0)
|
||||||
|
is_direct = Column(Boolean, default=False)
|
||||||
|
modified_on = Column(DateTime)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
17
app/models/tcgplayer_group.py
Normal file
17
app/models/tcgplayer_group.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.db.database import Base
|
||||||
|
|
||||||
|
class TCGPlayerGroup(Base):
|
||||||
|
__tablename__ = "tcgplayer_groups"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
group_id = Column(Integer, unique=True, index=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
abbreviation = Column(String)
|
||||||
|
is_supplemental = Column(Boolean, default=False)
|
||||||
|
published_on = Column(DateTime)
|
||||||
|
modified_on = Column(DateTime)
|
||||||
|
category_id = Column(Integer)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
27
app/models/tcgplayer_product.py
Normal file
27
app/models/tcgplayer_product.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.db.database import Base
|
||||||
|
|
||||||
|
class TCGPlayerProduct(Base):
|
||||||
|
__tablename__ = "tcgplayer_products"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
product_id = Column(Integer, index=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
clean_name = Column(String)
|
||||||
|
image_url = Column(String)
|
||||||
|
category_id = Column(Integer)
|
||||||
|
group_id = Column(Integer, ForeignKey("tcgplayer_groups.group_id"))
|
||||||
|
url = Column(String)
|
||||||
|
modified_on = Column(DateTime)
|
||||||
|
image_count = Column(Integer)
|
||||||
|
ext_rarity = Column(String)
|
||||||
|
ext_number = Column(String)
|
||||||
|
low_price = Column(Float)
|
||||||
|
mid_price = Column(Float)
|
||||||
|
high_price = Column(Float)
|
||||||
|
market_price = Column(Float)
|
||||||
|
direct_low_price = Column(Float)
|
||||||
|
sub_type_name = Column(String)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
@ -1,17 +1,25 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.models.file import File, FileCreate, FileUpdate, FileDelete, FileList
|
from app.models.file import File as FileModel
|
||||||
from app.models.box import Box, BoxCreate, BoxUpdate, BoxDelete, BoxList, OpenBox, OpenBoxCreate, OpenBoxUpdate, OpenBoxDelete, OpenBoxList
|
from app.schemas.file import FileCreate, FileUpdate, FileDelete, FileList, FileInDB
|
||||||
from app.models.game import Game, GameCreate, GameUpdate, GameDelete, GameList
|
from app.models.box import Box as BoxModel, OpenBox as OpenBoxModel
|
||||||
from app.models.card import Card, CardCreate, CardUpdate, CardDelete, CardList
|
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 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")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
# Initialize services
|
# Initialize services
|
||||||
card_service = CardService()
|
card_service = CardService()
|
||||||
order_service = OrderService()
|
order_service = OrderService()
|
||||||
|
file_processing_service = FileProcessingService()
|
||||||
|
tcgplayer_inventory_service = TCGPlayerInventoryService()
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Health Check & Root Endpoints
|
# Health Check & Root Endpoints
|
||||||
@ -37,7 +45,7 @@ async def get_cards(
|
|||||||
):
|
):
|
||||||
skip = (page - 1) * limit
|
skip = (page - 1) * limit
|
||||||
cards = card_service.get_all(db, skip=skip, limit=limit)
|
cards = card_service.get_all(db, skip=skip, limit=limit)
|
||||||
total = db.query(Card).count()
|
total = db.query(CardModel).count()
|
||||||
return {
|
return {
|
||||||
"cards": cards,
|
"cards": cards,
|
||||||
"total": total,
|
"total": total,
|
||||||
@ -45,7 +53,7 @@ async def get_cards(
|
|||||||
"limit": limit
|
"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)):
|
async def create_card(card: CardCreate, db: Session = Depends(get_db)):
|
||||||
try:
|
try:
|
||||||
created_card = card_service.create(db, card.dict())
|
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:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=str(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)):
|
async def update_card(card_id: int, card: CardUpdate, db: Session = Depends(get_db)):
|
||||||
db_card = card_service.get(db, card_id)
|
db_card = card_service.get(db, card_id)
|
||||||
if not db_card:
|
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):
|
async def get_files(page: int = 1, limit: int = 10, type: str = None, id: int = None):
|
||||||
return {"files": [], "total": 0, "page": page, "limit": limit}
|
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):
|
async def create_file(file: FileCreate):
|
||||||
return {"message": "File created successfully"}
|
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):
|
async def update_file(file_id: int, file: FileUpdate):
|
||||||
return {"message": "File updated successfully"}
|
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):
|
async def get_boxes(page: int = 1, limit: int = 10, type: str = None, id: int = None):
|
||||||
return {"boxes": [], "total": 0, "page": page, "limit": limit}
|
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):
|
async def create_box(box: BoxCreate):
|
||||||
return {"message": "Box created successfully"}
|
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):
|
async def update_box(box_id: int, box: BoxUpdate):
|
||||||
return {"message": "Box updated successfully"}
|
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):
|
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}
|
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):
|
async def create_open_box(open_box: OpenBoxCreate):
|
||||||
return {"message": "Open box created successfully"}
|
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):
|
async def update_open_box(open_box_id: int, open_box: OpenBoxUpdate):
|
||||||
return {"message": "Open box updated successfully"}
|
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):
|
async def get_games(page: int = 1, limit: int = 10, type: str = None, id: int = None):
|
||||||
return {"games": [], "total": 0, "page": page, "limit": limit}
|
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):
|
async def create_game(game: GameCreate):
|
||||||
return {"message": "Game created successfully"}
|
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):
|
async def update_game(game_id: int, game: GameUpdate):
|
||||||
return {"message": "Game updated successfully"}
|
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):
|
async def delete_game(game_id: int):
|
||||||
return {"message": "Game deleted successfully"}
|
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))
|
||||||
|
|
||||||
|
@ -1,50 +1,41 @@
|
|||||||
from pydantic import BaseModel, ConfigDict
|
from typing import List, Optional
|
||||||
from typing import Optional, List
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
# Base schema with common attributes
|
# Base schema with common attributes
|
||||||
class BoxBase(BaseModel):
|
class BoxBase(BaseModel):
|
||||||
product_id: int
|
|
||||||
type: str
|
|
||||||
set_code: str
|
|
||||||
sku: int
|
|
||||||
name: str
|
name: str
|
||||||
expected_number_of_cards: int
|
description: Optional[str] = None
|
||||||
description: str
|
game_id: int
|
||||||
image_url: str
|
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
|
# Schema for creating a new box
|
||||||
class BoxCreate(BoxBase):
|
class BoxCreate(BoxBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Schema for updating a box
|
# Schema for updating a box
|
||||||
class BoxUpdate(BaseModel):
|
class BoxUpdate(BoxBase):
|
||||||
product_id: Optional[int] = None
|
pass
|
||||||
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
|
|
||||||
|
|
||||||
# Schema for reading a box
|
# Schema for reading a box
|
||||||
class Box(BoxBase):
|
class BoxInDB(BoxBase):
|
||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: Optional[datetime] = None
|
||||||
cards: List["Card"] = []
|
|
||||||
open_boxes: List["OpenBox"] = []
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
# Schema for deleting a box
|
# Schema for deleting a box
|
||||||
class BoxDelete(BaseModel):
|
class BoxDelete(BaseModel):
|
||||||
id: int
|
message: str
|
||||||
|
|
||||||
# Schema for listing boxes
|
# Schema for listing boxes
|
||||||
class BoxList(BaseModel):
|
class BoxList(BaseModel):
|
||||||
boxes: List[Box]
|
boxes: List[BoxInDB]
|
||||||
total: int
|
total: int
|
||||||
page: int
|
page: int
|
||||||
limit: int
|
limit: int
|
||||||
@ -52,30 +43,30 @@ class BoxList(BaseModel):
|
|||||||
# OpenBox schemas
|
# OpenBox schemas
|
||||||
class OpenBoxBase(BaseModel):
|
class OpenBoxBase(BaseModel):
|
||||||
box_id: int
|
box_id: int
|
||||||
number_of_cards: int
|
opened_at: Optional[datetime] = None
|
||||||
date_opened: datetime
|
opened_by: Optional[str] = None
|
||||||
|
contents: Optional[List[dict]] = None
|
||||||
|
status: Optional[str] = "pending" # pending, opened, verified, listed
|
||||||
|
|
||||||
class OpenBoxCreate(OpenBoxBase):
|
class OpenBoxCreate(OpenBoxBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class OpenBoxUpdate(BaseModel):
|
class OpenBoxUpdate(OpenBoxBase):
|
||||||
number_of_cards: Optional[int] = None
|
pass
|
||||||
date_opened: Optional[datetime] = None
|
|
||||||
|
|
||||||
class OpenBox(OpenBoxBase):
|
class OpenBoxInDB(OpenBoxBase):
|
||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: Optional[datetime] = None
|
||||||
box: Optional[Box] = None
|
|
||||||
cards: List["Card"] = []
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
class OpenBoxDelete(BaseModel):
|
class OpenBoxDelete(BaseModel):
|
||||||
id: int
|
message: str
|
||||||
|
|
||||||
class OpenBoxList(BaseModel):
|
class OpenBoxList(BaseModel):
|
||||||
open_boxes: List[OpenBox]
|
open_boxes: List[OpenBoxInDB]
|
||||||
total: int
|
total: int
|
||||||
page: int
|
page: int
|
||||||
limit: int
|
limit: int
|
@ -1,39 +1,55 @@
|
|||||||
from pydantic import BaseModel, ConfigDict
|
from typing import List, Optional
|
||||||
from typing import Optional, List
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
# Base schema with common attributes
|
# Base schema with common attributes
|
||||||
class CardBase(BaseModel):
|
class CardBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
rarity: str
|
rarity: Optional[str] = None
|
||||||
set_name: str
|
set_name: Optional[str] = None
|
||||||
price: float
|
price: Optional[float] = None
|
||||||
quantity: int = 0
|
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
|
# Schema for creating a new card
|
||||||
class CardCreate(CardBase):
|
class CardCreate(CardBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Schema for updating a card
|
# Schema for updating a card
|
||||||
class CardUpdate(BaseModel):
|
class CardUpdate(CardBase):
|
||||||
name: Optional[str] = None
|
pass
|
||||||
rarity: Optional[str] = None
|
|
||||||
set_name: Optional[str] = None
|
|
||||||
price: Optional[float] = None
|
|
||||||
quantity: Optional[int] = None
|
|
||||||
|
|
||||||
# Schema for reading a card (includes id and relationships)
|
# Schema for reading a card (includes id and relationships)
|
||||||
class Card(CardBase):
|
class CardInDB(CardBase):
|
||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: Optional[datetime] = None
|
||||||
orders: List["Order"] = []
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
# Schema for listing cards
|
# Schema for listing cards
|
||||||
class CardList(BaseModel):
|
class CardList(BaseModel):
|
||||||
cards: List[Card]
|
cards: List[CardInDB]
|
||||||
total: int
|
total: int
|
||||||
page: int
|
page: int
|
||||||
limit: int
|
limit: int
|
||||||
|
|
||||||
|
# Schema for deleting a card
|
||||||
|
class CardDelete(BaseModel):
|
||||||
|
message: str
|
@ -1,38 +1,40 @@
|
|||||||
from pydantic import BaseModel, ConfigDict
|
from typing import List, Optional
|
||||||
from typing import Optional, List
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
# Base schema with common attributes
|
# Base schema with common attributes
|
||||||
class FileBase(BaseModel):
|
class FileBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
type: str
|
|
||||||
path: 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
|
# Schema for creating a new file
|
||||||
class FileCreate(FileBase):
|
class FileCreate(FileBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Schema for updating a file
|
# Schema for updating a file
|
||||||
class FileUpdate(BaseModel):
|
class FileUpdate(FileBase):
|
||||||
name: Optional[str] = None
|
pass
|
||||||
type: Optional[str] = None
|
|
||||||
path: Optional[str] = None
|
|
||||||
|
|
||||||
# Schema for reading a file
|
# Schema for reading a file
|
||||||
class File(FileBase):
|
class FileInDB(FileBase):
|
||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
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
|
# Schema for deleting a file
|
||||||
class FileDelete(BaseModel):
|
class FileDelete(BaseModel):
|
||||||
id: int
|
message: str
|
||||||
|
|
||||||
# Schema for listing files
|
# Schema for listing files
|
||||||
class FileList(BaseModel):
|
class FileList(BaseModel):
|
||||||
files: List[File]
|
files: List[FileInDB]
|
||||||
total: int
|
total: int
|
||||||
page: int
|
page: int
|
||||||
limit: int
|
limit: int
|
@ -1,39 +1,41 @@
|
|||||||
from pydantic import BaseModel, ConfigDict
|
from typing import List, Optional
|
||||||
from typing import Optional, List
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
# Base schema with common attributes
|
# Base schema with common attributes
|
||||||
class GameBase(BaseModel):
|
class GameBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: str
|
publisher: Optional[str] = None
|
||||||
image_url: str
|
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
|
# Schema for creating a new game
|
||||||
class GameCreate(GameBase):
|
class GameCreate(GameBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Schema for updating a game
|
# Schema for updating a game
|
||||||
class GameUpdate(BaseModel):
|
class GameUpdate(GameBase):
|
||||||
name: Optional[str] = None
|
pass
|
||||||
description: Optional[str] = None
|
|
||||||
image_url: Optional[str] = None
|
|
||||||
|
|
||||||
# Schema for reading a game
|
# Schema for reading a game
|
||||||
class Game(GameBase):
|
class GameInDB(GameBase):
|
||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: Optional[datetime] = None
|
||||||
boxes: List["Box"] = []
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
# Schema for deleting a game
|
# Schema for deleting a game
|
||||||
class GameDelete(BaseModel):
|
class GameDelete(BaseModel):
|
||||||
id: int
|
message: str
|
||||||
|
|
||||||
# Schema for listing games
|
# Schema for listing games
|
||||||
class GameList(BaseModel):
|
class GameList(BaseModel):
|
||||||
games: List[Game]
|
games: List[GameInDB]
|
||||||
total: int
|
total: int
|
||||||
page: int
|
page: int
|
||||||
limit: int
|
limit: int
|
@ -1,5 +1,15 @@
|
|||||||
from app.services.base_service import BaseService
|
from app.services.base_service import BaseService
|
||||||
from app.services.card_service import CardService
|
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
|
from app.services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
__all__ = ["BaseService", "CardService", "ServiceRegistry"]
|
__all__ = [
|
||||||
|
'BaseService',
|
||||||
|
'CardService',
|
||||||
|
'OrderService',
|
||||||
|
'FileProcessingService',
|
||||||
|
'InventoryService',
|
||||||
|
'ServiceRegistry'
|
||||||
|
]
|
@ -1,17 +1,126 @@
|
|||||||
from typing import List, Optional
|
from typing import List, Optional, Dict
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.services.base_service import BaseService
|
|
||||||
from app.models.card import Card
|
from app.models.card import Card
|
||||||
|
from app.services.base_service import BaseService
|
||||||
|
from app.schemas.card import CardCreate, CardUpdate
|
||||||
|
|
||||||
class CardService(BaseService[Card]):
|
class CardService(BaseService[Card]):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(Card)
|
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]:
|
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()
|
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]:
|
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()
|
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]:
|
def update_quantity(self, db: Session, card_id: int, quantity_change: int) -> Card:
|
||||||
return db.query(self.model).filter(self.model.set_name == set_name).offset(skip).limit(limit).all()
|
"""
|
||||||
|
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()
|
201
app/services/data_initialization.py
Normal file
201
app/services/data_initialization.py
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.services.external_api.tcgcsv.tcgcsv_service import TCGCSVService
|
||||||
|
from app.models.tcgplayer_group import TCGPlayerGroup
|
||||||
|
from app.models.tcgplayer_product import TCGPlayerProduct
|
||||||
|
from app.models.tcgplayer_category import TCGPlayerCategory
|
||||||
|
|
||||||
|
class DataInitializationService:
|
||||||
|
def __init__(self, cache_dir: str = "app/data/cache/tcgcsv"):
|
||||||
|
self.cache_dir = cache_dir
|
||||||
|
self.tcgcsv_service = TCGCSVService()
|
||||||
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
|
|
||||||
|
def _get_cache_path(self, filename: str) -> str:
|
||||||
|
return os.path.join(self.cache_dir, filename)
|
||||||
|
|
||||||
|
async def _cache_categories(self, categories_data: dict):
|
||||||
|
"""Cache categories data to a JSON file"""
|
||||||
|
cache_path = self._get_cache_path("categories.json")
|
||||||
|
with open(cache_path, 'w') as f:
|
||||||
|
json.dump(categories_data, f, indent=2)
|
||||||
|
|
||||||
|
async def _cache_groups(self, game_ids: List[int], groups_data: dict):
|
||||||
|
for game_id in game_ids:
|
||||||
|
cache_path = self._get_cache_path(f"groups_{game_id}.json")
|
||||||
|
with open(cache_path, 'w') as f:
|
||||||
|
json.dump(groups_data, f, default=str)
|
||||||
|
|
||||||
|
async def _cache_products(self, game_ids: List[int], group_id: int, products_data: list):
|
||||||
|
for game_id in game_ids:
|
||||||
|
cache_path = self._get_cache_path(f"products_{game_id}_{group_id}.json")
|
||||||
|
with open(cache_path, 'w') as f:
|
||||||
|
json.dump(products_data, f, default=str)
|
||||||
|
|
||||||
|
async def _load_cached_categories(self) -> Optional[dict]:
|
||||||
|
cache_path = self._get_cache_path("categories.json")
|
||||||
|
if os.path.exists(cache_path):
|
||||||
|
with open(cache_path, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _load_cached_groups(self, game_ids: List[int]) -> Optional[dict]:
|
||||||
|
# Try to load cached data for any of the game IDs
|
||||||
|
for game_id in game_ids:
|
||||||
|
cache_path = self._get_cache_path(f"groups_{game_id}.json")
|
||||||
|
if os.path.exists(cache_path):
|
||||||
|
with open(cache_path, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _load_cached_products(self, game_ids: List[int], group_id: int) -> Optional[list]:
|
||||||
|
# Try to load cached data for any of the game IDs
|
||||||
|
for game_id in game_ids:
|
||||||
|
cache_path = self._get_cache_path(f"products_{game_id}_{group_id}.json")
|
||||||
|
if os.path.exists(cache_path):
|
||||||
|
with open(cache_path, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def initialize_data(self, db: Session, game_ids: List[int], use_cache: bool = True) -> None:
|
||||||
|
"""Initialize TCGPlayer data, using cache if available and requested"""
|
||||||
|
print("Initializing TCGPlayer data...")
|
||||||
|
|
||||||
|
# Handle categories
|
||||||
|
categories_data = None
|
||||||
|
if use_cache:
|
||||||
|
categories_data = await self._load_cached_categories()
|
||||||
|
|
||||||
|
if not categories_data:
|
||||||
|
print("Fetching categories from API...")
|
||||||
|
categories_data = await self.tcgcsv_service.get_categories()
|
||||||
|
if use_cache:
|
||||||
|
await self._cache_categories(categories_data)
|
||||||
|
|
||||||
|
if not categories_data.get("success"):
|
||||||
|
raise Exception(f"Failed to fetch categories: {categories_data.get('errors')}")
|
||||||
|
|
||||||
|
# Sync categories to database
|
||||||
|
categories = categories_data.get("results", [])
|
||||||
|
synced_categories = []
|
||||||
|
for category_data in categories:
|
||||||
|
existing_category = db.query(TCGPlayerCategory).filter(TCGPlayerCategory.category_id == category_data["categoryId"]).first()
|
||||||
|
if existing_category:
|
||||||
|
synced_categories.append(existing_category)
|
||||||
|
else:
|
||||||
|
new_category = TCGPlayerCategory(
|
||||||
|
category_id=category_data["categoryId"],
|
||||||
|
name=category_data["name"],
|
||||||
|
display_name=category_data.get("displayName"),
|
||||||
|
seo_category_name=category_data.get("seoCategoryName"),
|
||||||
|
category_description=category_data.get("categoryDescription"),
|
||||||
|
category_page_title=category_data.get("categoryPageTitle"),
|
||||||
|
sealed_label=category_data.get("sealedLabel"),
|
||||||
|
non_sealed_label=category_data.get("nonSealedLabel"),
|
||||||
|
condition_guide_url=category_data.get("conditionGuideUrl"),
|
||||||
|
is_scannable=category_data.get("isScannable", False),
|
||||||
|
popularity=category_data.get("popularity", 0),
|
||||||
|
is_direct=category_data.get("isDirect", False),
|
||||||
|
modified_on=datetime.fromisoformat(category_data["modifiedOn"].replace("Z", "+00:00")) if category_data.get("modifiedOn") else None
|
||||||
|
)
|
||||||
|
db.add(new_category)
|
||||||
|
synced_categories.append(new_category)
|
||||||
|
db.commit()
|
||||||
|
print(f"Synced {len(synced_categories)} categories")
|
||||||
|
|
||||||
|
# Process each game ID separately
|
||||||
|
for game_id in game_ids:
|
||||||
|
print(f"\nProcessing game ID: {game_id}")
|
||||||
|
|
||||||
|
# Handle groups for this game ID
|
||||||
|
groups_data = None
|
||||||
|
if use_cache:
|
||||||
|
groups_data = await self._load_cached_groups([game_id])
|
||||||
|
|
||||||
|
if not groups_data:
|
||||||
|
print(f"Fetching groups for game ID {game_id} from API...")
|
||||||
|
groups_data = await self.tcgcsv_service.get_groups([game_id])
|
||||||
|
if use_cache:
|
||||||
|
await self._cache_groups([game_id], groups_data)
|
||||||
|
|
||||||
|
if not groups_data.get("success"):
|
||||||
|
raise Exception(f"Failed to fetch groups for game ID {game_id}: {groups_data.get('errors')}")
|
||||||
|
|
||||||
|
# Sync groups to database
|
||||||
|
groups = groups_data.get("results", [])
|
||||||
|
synced_groups = []
|
||||||
|
for group_data in groups:
|
||||||
|
existing_group = db.query(TCGPlayerGroup).filter(TCGPlayerGroup.group_id == group_data["groupId"]).first()
|
||||||
|
if existing_group:
|
||||||
|
synced_groups.append(existing_group)
|
||||||
|
else:
|
||||||
|
new_group = TCGPlayerGroup(
|
||||||
|
group_id=group_data["groupId"],
|
||||||
|
name=group_data["name"],
|
||||||
|
abbreviation=group_data.get("abbreviation"),
|
||||||
|
is_supplemental=group_data.get("isSupplemental", False),
|
||||||
|
published_on=datetime.fromisoformat(group_data["publishedOn"].replace("Z", "+00:00")) if group_data.get("publishedOn") else None,
|
||||||
|
modified_on=datetime.fromisoformat(group_data["modifiedOn"].replace("Z", "+00:00")) if group_data.get("modifiedOn") else None,
|
||||||
|
category_id=group_data.get("categoryId")
|
||||||
|
)
|
||||||
|
db.add(new_group)
|
||||||
|
synced_groups.append(new_group)
|
||||||
|
db.commit()
|
||||||
|
print(f"Synced {len(synced_groups)} groups for game ID {game_id}")
|
||||||
|
|
||||||
|
# Handle products for each group in this game ID
|
||||||
|
for group in synced_groups:
|
||||||
|
products_data = None
|
||||||
|
if use_cache:
|
||||||
|
products_data = await self._load_cached_products([game_id], group.group_id)
|
||||||
|
|
||||||
|
if not products_data:
|
||||||
|
print(f"Fetching products for group {group.name} (game ID {game_id}) from API...")
|
||||||
|
products_data = await self.tcgcsv_service.get_products_and_prices([game_id], group.group_id)
|
||||||
|
if use_cache:
|
||||||
|
await self._cache_products([game_id], group.group_id, products_data)
|
||||||
|
|
||||||
|
# Sync products to database
|
||||||
|
synced_products = []
|
||||||
|
for product_data in products_data:
|
||||||
|
existing_product = db.query(TCGPlayerProduct).filter(TCGPlayerProduct.product_id == int(product_data["productId"])).first()
|
||||||
|
if existing_product:
|
||||||
|
synced_products.append(existing_product)
|
||||||
|
else:
|
||||||
|
new_product = TCGPlayerProduct(
|
||||||
|
product_id=int(product_data["productId"]),
|
||||||
|
name=product_data["name"],
|
||||||
|
clean_name=product_data.get("cleanName"),
|
||||||
|
image_url=product_data.get("imageUrl"),
|
||||||
|
category_id=int(product_data["categoryId"]),
|
||||||
|
group_id=int(product_data["groupId"]),
|
||||||
|
url=product_data.get("url"),
|
||||||
|
modified_on=datetime.fromisoformat(product_data["modifiedOn"].replace("Z", "+00:00")) if product_data.get("modifiedOn") else None,
|
||||||
|
image_count=int(product_data.get("imageCount", 0)),
|
||||||
|
ext_rarity=product_data.get("extRarity"),
|
||||||
|
ext_number=product_data.get("extNumber"),
|
||||||
|
low_price=float(product_data.get("lowPrice")) if product_data.get("lowPrice") else None,
|
||||||
|
mid_price=float(product_data.get("midPrice")) if product_data.get("midPrice") else None,
|
||||||
|
high_price=float(product_data.get("highPrice")) if product_data.get("highPrice") else None,
|
||||||
|
market_price=float(product_data.get("marketPrice")) if product_data.get("marketPrice") else None,
|
||||||
|
direct_low_price=float(product_data.get("directLowPrice")) if product_data.get("directLowPrice") else None,
|
||||||
|
sub_type_name=product_data.get("subTypeName")
|
||||||
|
)
|
||||||
|
db.add(new_product)
|
||||||
|
synced_products.append(new_product)
|
||||||
|
db.commit()
|
||||||
|
print(f"Synced {len(synced_products)} products for group {group.name} (game ID {game_id})")
|
||||||
|
|
||||||
|
async def clear_cache(self) -> None:
|
||||||
|
"""Clear all cached data"""
|
||||||
|
for filename in os.listdir(self.cache_dir):
|
||||||
|
file_path = os.path.join(self.cache_dir, filename)
|
||||||
|
if os.path.isfile(file_path):
|
||||||
|
os.unlink(file_path)
|
||||||
|
print("Cache cleared")
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
await self.tcgcsv_service.close()
|
@ -1,7 +1,8 @@
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional, Union
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import logging
|
import logging
|
||||||
from app.services.service_registry import ServiceRegistry
|
from app.services.service_registry import ServiceRegistry
|
||||||
|
import json
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -24,8 +25,9 @@ class BaseExternalService:
|
|||||||
endpoint: str,
|
endpoint: str,
|
||||||
params: Optional[Dict[str, Any]] = None,
|
params: Optional[Dict[str, Any]] = None,
|
||||||
headers: Optional[Dict[str, str]] = None,
|
headers: Optional[Dict[str, str]] = None,
|
||||||
data: Optional[Dict[str, Any]] = None
|
data: Optional[Dict[str, Any]] = None,
|
||||||
) -> Dict[str, Any]:
|
content_type: str = "application/json"
|
||||||
|
) -> Union[Dict[str, Any], str]:
|
||||||
session = await self._get_session()
|
session = await self._get_session()
|
||||||
url = f"{self.base_url}{endpoint}"
|
url = f"{self.base_url}{endpoint}"
|
||||||
|
|
||||||
@ -36,9 +38,30 @@ class BaseExternalService:
|
|||||||
try:
|
try:
|
||||||
async with session.request(method, url, params=params, headers=headers, json=data) as response:
|
async with session.request(method, url, params=params, headers=headers, json=data) as response:
|
||||||
response.raise_for_status()
|
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:
|
except aiohttp.ClientError as e:
|
||||||
logger.error(f"API request failed: {str(e)}")
|
logger.error(f"Request failed: {e}")
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error during API request: {str(e)}")
|
logger.error(f"Unexpected error during API request: {str(e)}")
|
||||||
|
205
app/services/external_api/tcgcsv/tcgcsv_service.py
Normal file
205
app/services/external_api/tcgcsv/tcgcsv_service.py
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
from typing import List, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from app.services.external_api.base_external_service import BaseExternalService
|
||||||
|
from app.models.tcgplayer_group import TCGPlayerGroup
|
||||||
|
from app.models.tcgplayer_product import TCGPlayerProduct
|
||||||
|
from app.models.tcgplayer_category import TCGPlayerCategory
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
class TCGCSVService(BaseExternalService):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(base_url="https://tcgcsv.com/tcgplayer/")
|
||||||
|
|
||||||
|
async def get_groups(self, game_ids: List[int]) -> Dict[str, Any]:
|
||||||
|
"""Fetch groups for specific game IDs from TCGCSV API"""
|
||||||
|
game_ids_str = ",".join(map(str, game_ids))
|
||||||
|
endpoint = f"{game_ids_str}/groups"
|
||||||
|
return await self._make_request("GET", endpoint)
|
||||||
|
|
||||||
|
async def get_products_and_prices(self, game_ids: List[int], group_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""Fetch products and prices for a specific group from TCGCSV API"""
|
||||||
|
game_ids_str = ",".join(map(str, game_ids))
|
||||||
|
endpoint = f"{game_ids_str}/{group_id}/ProductsAndPrices.csv"
|
||||||
|
response = await self._make_request("GET", endpoint, headers={"Accept": "text/csv"})
|
||||||
|
|
||||||
|
# Parse CSV response
|
||||||
|
csv_data = io.StringIO(response)
|
||||||
|
reader = csv.DictReader(csv_data)
|
||||||
|
return list(reader)
|
||||||
|
|
||||||
|
async def get_categories(self) -> Dict[str, Any]:
|
||||||
|
"""Fetch all categories from TCGCSV API"""
|
||||||
|
endpoint = "categories"
|
||||||
|
return await self._make_request("GET", endpoint)
|
||||||
|
|
||||||
|
async def sync_groups_to_db(self, db: Session, game_ids: List[int]) -> List[TCGPlayerGroup]:
|
||||||
|
"""Fetch groups from API and sync them to the database"""
|
||||||
|
response = await self.get_groups(game_ids)
|
||||||
|
|
||||||
|
if not response.get("success"):
|
||||||
|
raise Exception(f"Failed to fetch groups: {response.get('errors')}")
|
||||||
|
|
||||||
|
groups = response.get("results", [])
|
||||||
|
synced_groups = []
|
||||||
|
|
||||||
|
for group_data in groups:
|
||||||
|
# Convert string dates to datetime objects
|
||||||
|
published_on = datetime.fromisoformat(group_data["publishedOn"].replace("Z", "+00:00")) if group_data.get("publishedOn") else None
|
||||||
|
modified_on = datetime.fromisoformat(group_data["modifiedOn"].replace("Z", "+00:00")) if group_data.get("modifiedOn") else None
|
||||||
|
|
||||||
|
# Check if group already exists
|
||||||
|
existing_group = db.query(TCGPlayerGroup).filter(TCGPlayerGroup.group_id == group_data["groupId"]).first()
|
||||||
|
|
||||||
|
if existing_group:
|
||||||
|
# Update existing group
|
||||||
|
for key, value in {
|
||||||
|
"name": group_data["name"],
|
||||||
|
"abbreviation": group_data.get("abbreviation"),
|
||||||
|
"is_supplemental": group_data.get("isSupplemental", False),
|
||||||
|
"published_on": published_on,
|
||||||
|
"modified_on": modified_on,
|
||||||
|
"category_id": group_data.get("categoryId")
|
||||||
|
}.items():
|
||||||
|
setattr(existing_group, key, value)
|
||||||
|
synced_groups.append(existing_group)
|
||||||
|
else:
|
||||||
|
# Create new group
|
||||||
|
new_group = TCGPlayerGroup(
|
||||||
|
group_id=group_data["groupId"],
|
||||||
|
name=group_data["name"],
|
||||||
|
abbreviation=group_data.get("abbreviation"),
|
||||||
|
is_supplemental=group_data.get("isSupplemental", False),
|
||||||
|
published_on=published_on,
|
||||||
|
modified_on=modified_on,
|
||||||
|
category_id=group_data.get("categoryId")
|
||||||
|
)
|
||||||
|
db.add(new_group)
|
||||||
|
synced_groups.append(new_group)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return synced_groups
|
||||||
|
|
||||||
|
async def sync_products_to_db(self, db: Session, game_id: int, group_id: int) -> List[TCGPlayerProduct]:
|
||||||
|
"""Fetch products and prices for a group and sync them to the database"""
|
||||||
|
products_data = await self.get_products_and_prices(game_id, group_id)
|
||||||
|
synced_products = []
|
||||||
|
|
||||||
|
for product_data in products_data:
|
||||||
|
# Convert string dates to datetime objects
|
||||||
|
modified_on = datetime.fromisoformat(product_data["modifiedOn"].replace("Z", "+00:00")) if product_data.get("modifiedOn") else None
|
||||||
|
|
||||||
|
# Convert price strings to floats, handling empty strings
|
||||||
|
def parse_price(price_str):
|
||||||
|
return float(price_str) if price_str else None
|
||||||
|
|
||||||
|
# Check if product already exists
|
||||||
|
existing_product = db.query(TCGPlayerProduct).filter(TCGPlayerProduct.product_id == int(product_data["productId"])).first()
|
||||||
|
|
||||||
|
if existing_product:
|
||||||
|
# Update existing product
|
||||||
|
for key, value in {
|
||||||
|
"name": product_data["name"],
|
||||||
|
"clean_name": product_data.get("cleanName"),
|
||||||
|
"image_url": product_data.get("imageUrl"),
|
||||||
|
"category_id": int(product_data["categoryId"]),
|
||||||
|
"group_id": int(product_data["groupId"]),
|
||||||
|
"url": product_data.get("url"),
|
||||||
|
"modified_on": modified_on,
|
||||||
|
"image_count": int(product_data.get("imageCount", 0)),
|
||||||
|
"ext_rarity": product_data.get("extRarity"),
|
||||||
|
"ext_number": product_data.get("extNumber"),
|
||||||
|
"low_price": parse_price(product_data.get("lowPrice")),
|
||||||
|
"mid_price": parse_price(product_data.get("midPrice")),
|
||||||
|
"high_price": parse_price(product_data.get("highPrice")),
|
||||||
|
"market_price": parse_price(product_data.get("marketPrice")),
|
||||||
|
"direct_low_price": parse_price(product_data.get("directLowPrice")),
|
||||||
|
"sub_type_name": product_data.get("subTypeName")
|
||||||
|
}.items():
|
||||||
|
setattr(existing_product, key, value)
|
||||||
|
synced_products.append(existing_product)
|
||||||
|
else:
|
||||||
|
# Create new product
|
||||||
|
new_product = TCGPlayerProduct(
|
||||||
|
product_id=int(product_data["productId"]),
|
||||||
|
name=product_data["name"],
|
||||||
|
clean_name=product_data.get("cleanName"),
|
||||||
|
image_url=product_data.get("imageUrl"),
|
||||||
|
category_id=int(product_data["categoryId"]),
|
||||||
|
group_id=int(product_data["groupId"]),
|
||||||
|
url=product_data.get("url"),
|
||||||
|
modified_on=modified_on,
|
||||||
|
image_count=int(product_data.get("imageCount", 0)),
|
||||||
|
ext_rarity=product_data.get("extRarity"),
|
||||||
|
ext_number=product_data.get("extNumber"),
|
||||||
|
low_price=parse_price(product_data.get("lowPrice")),
|
||||||
|
mid_price=parse_price(product_data.get("midPrice")),
|
||||||
|
high_price=parse_price(product_data.get("highPrice")),
|
||||||
|
market_price=parse_price(product_data.get("marketPrice")),
|
||||||
|
direct_low_price=parse_price(product_data.get("directLowPrice")),
|
||||||
|
sub_type_name=product_data.get("subTypeName")
|
||||||
|
)
|
||||||
|
db.add(new_product)
|
||||||
|
synced_products.append(new_product)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return synced_products
|
||||||
|
|
||||||
|
async def sync_categories_to_db(self, db: Session) -> List[TCGPlayerCategory]:
|
||||||
|
"""Fetch categories from API and sync them to the database"""
|
||||||
|
response = await self.get_categories()
|
||||||
|
|
||||||
|
if not response.get("success"):
|
||||||
|
raise Exception(f"Failed to fetch categories: {response.get('errors')}")
|
||||||
|
|
||||||
|
categories = response.get("results", [])
|
||||||
|
synced_categories = []
|
||||||
|
|
||||||
|
for category_data in categories:
|
||||||
|
# Convert string dates to datetime objects
|
||||||
|
modified_on = datetime.fromisoformat(category_data["modifiedOn"].replace("Z", "+00:00")) if category_data.get("modifiedOn") else None
|
||||||
|
|
||||||
|
# Check if category already exists
|
||||||
|
existing_category = db.query(TCGPlayerCategory).filter(TCGPlayerCategory.category_id == category_data["categoryId"]).first()
|
||||||
|
|
||||||
|
if existing_category:
|
||||||
|
# Update existing category
|
||||||
|
for key, value in {
|
||||||
|
"name": category_data["name"],
|
||||||
|
"display_name": category_data.get("displayName"),
|
||||||
|
"seo_category_name": category_data.get("seoCategoryName"),
|
||||||
|
"category_description": category_data.get("categoryDescription"),
|
||||||
|
"category_page_title": category_data.get("categoryPageTitle"),
|
||||||
|
"sealed_label": category_data.get("sealedLabel"),
|
||||||
|
"non_sealed_label": category_data.get("nonSealedLabel"),
|
||||||
|
"condition_guide_url": category_data.get("conditionGuideUrl"),
|
||||||
|
"is_scannable": category_data.get("isScannable", False),
|
||||||
|
"popularity": category_data.get("popularity", 0),
|
||||||
|
"is_direct": category_data.get("isDirect", False),
|
||||||
|
"modified_on": modified_on
|
||||||
|
}.items():
|
||||||
|
setattr(existing_category, key, value)
|
||||||
|
synced_categories.append(existing_category)
|
||||||
|
else:
|
||||||
|
# Create new category
|
||||||
|
new_category = TCGPlayerCategory(
|
||||||
|
category_id=category_data["categoryId"],
|
||||||
|
name=category_data["name"],
|
||||||
|
display_name=category_data.get("displayName"),
|
||||||
|
seo_category_name=category_data.get("seoCategoryName"),
|
||||||
|
category_description=category_data.get("categoryDescription"),
|
||||||
|
category_page_title=category_data.get("categoryPageTitle"),
|
||||||
|
sealed_label=category_data.get("sealedLabel"),
|
||||||
|
non_sealed_label=category_data.get("nonSealedLabel"),
|
||||||
|
condition_guide_url=category_data.get("conditionGuideUrl"),
|
||||||
|
is_scannable=category_data.get("isScannable", False),
|
||||||
|
popularity=category_data.get("popularity", 0),
|
||||||
|
is_direct=category_data.get("isDirect", False),
|
||||||
|
modified_on=modified_on
|
||||||
|
)
|
||||||
|
db.add(new_category)
|
||||||
|
synced_categories.append(new_category)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return synced_categories
|
@ -1,4 +1,4 @@
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional, Union
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import logging
|
import logging
|
||||||
from app.services.external_api.base_external_service import BaseExternalService
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class BaseTCGPlayerService(BaseExternalService):
|
class BaseTCGPlayerService(BaseExternalService):
|
||||||
|
STORE_BASE_URL = "https://store.tcgplayer.com"
|
||||||
|
LOGIN_ENDPOINT = "/oauth/login"
|
||||||
|
PRICING_ENDPOINT = "/Admin/Pricing"
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(
|
super().__init__(base_url=self.STORE_BASE_URL)
|
||||||
store_base_url="https://store.tcgplayer.com",
|
|
||||||
login_endpoint="/oauth/login",
|
# Set up endpoints
|
||||||
pricing_endpoint="/Admin/Pricing",
|
self.login_endpoint = self.LOGIN_ENDPOINT
|
||||||
staged_inventory_endpoint=self.pricing_endpoint + "/DownloadStagedInventoryExportCSV?type=Pricing",
|
self.pricing_endpoint = self.PRICING_ENDPOINT
|
||||||
live_inventory_endpoint=self.pricing_endpoint + "/DownloadMyExportCSV?type=Pricing"
|
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()
|
self.credentials = TCGPlayerCredentials()
|
||||||
|
|
||||||
def _get_headers(self, method: str) -> Dict[str, str]:
|
def _get_headers(self, method: str) -> Dict[str, str]:
|
||||||
@ -53,10 +59,11 @@ class BaseTCGPlayerService(BaseExternalService):
|
|||||||
params: Optional[Dict[str, Any]] = None,
|
params: Optional[Dict[str, Any]] = None,
|
||||||
headers: Optional[Dict[str, str]] = None,
|
headers: Optional[Dict[str, str]] = None,
|
||||||
data: Optional[Dict[str, Any]] = None,
|
data: Optional[Dict[str, Any]] = None,
|
||||||
auth_required: bool = True
|
auth_required: bool = True,
|
||||||
) -> Dict[str, Any]:
|
download_file: bool = False
|
||||||
|
) -> Union[Dict[str, Any], bytes]:
|
||||||
session = await self._get_session()
|
session = await self._get_session()
|
||||||
url = f"{self.store_base_url}{endpoint}"
|
url = f"{self.base_url}{endpoint}"
|
||||||
|
|
||||||
# Get the authentication cookie if required
|
# Get the authentication cookie if required
|
||||||
if auth_required:
|
if auth_required:
|
||||||
@ -77,6 +84,9 @@ class BaseTCGPlayerService(BaseExternalService):
|
|||||||
if response.status == 401:
|
if response.status == 401:
|
||||||
raise RuntimeError("TCGPlayer authentication failed. Cookie may be invalid or expired.")
|
raise RuntimeError("TCGPlayer authentication failed. Cookie may be invalid or expired.")
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
|
if download_file:
|
||||||
|
return await response.read()
|
||||||
return await response.json()
|
return await response.json()
|
||||||
except aiohttp.ClientError as e:
|
except aiohttp.ClientError as e:
|
||||||
logger.error(f"TCGPlayer API request failed: {str(e)}")
|
logger.error(f"TCGPlayer API request failed: {str(e)}")
|
||||||
|
@ -5,14 +5,17 @@ class TCGPlayerInventoryService(BaseTCGPlayerService):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
async def get_inventory(self) -> List[Dict]:
|
async def get_tcgplayer_export(self, export_type: str):
|
||||||
"""Get inventory items"""
|
"""
|
||||||
endpoint = "/inventory"
|
Get a TCGPlayer Staged Inventory Export, Live Inventory Export, or Pricing Export
|
||||||
response = await self._make_request("GET", endpoint)
|
"""
|
||||||
return response.get("results", [])
|
if export_type == "staged":
|
||||||
|
endpoint = self.staged_inventory_endpoint
|
||||||
async def update_inventory(self, updates: List[Dict]) -> Dict:
|
elif export_type == "live":
|
||||||
"""Update inventory items"""
|
endpoint = self.live_inventory_endpoint
|
||||||
endpoint = "/inventory"
|
elif export_type == "pricing":
|
||||||
response = await self._make_request("PUT", endpoint, data=updates)
|
endpoint = self.pricing_export_endpoint
|
||||||
return response
|
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
|
@ -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
|
|
146
app/services/file_processing_service.py
Normal file
146
app/services/file_processing_service.py
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
from typing import Optional, List, Dict
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.db.database import transaction
|
||||||
|
from app.models.inventory import Inventory
|
||||||
|
from app.models.tcgplayer_product import TCGPlayerProduct
|
||||||
|
from app.services.inventory_service import InventoryService
|
||||||
|
|
||||||
|
class FileProcessingService:
|
||||||
|
def __init__(self, cache_dir: str = "app/data/cache/tcgplayer"):
|
||||||
|
self.cache_dir = cache_dir
|
||||||
|
self.inventory_service = InventoryService()
|
||||||
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
|
|
||||||
|
def _get_cache_path(self, filename: str) -> str:
|
||||||
|
return os.path.join(self.cache_dir, filename)
|
||||||
|
|
||||||
|
async def _cache_export(self, file_bytes: bytes, export_type: str):
|
||||||
|
cache_path = self._get_cache_path(f"{export_type}_export.csv")
|
||||||
|
with open(cache_path, 'wb') as f:
|
||||||
|
f.write(file_bytes)
|
||||||
|
|
||||||
|
async def _load_cached_export(self, export_type: str) -> Optional[bytes]:
|
||||||
|
cache_path = self._get_cache_path(f"{export_type}_export.csv")
|
||||||
|
if os.path.exists(cache_path):
|
||||||
|
with open(cache_path, 'rb') as f:
|
||||||
|
return f.read()
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def process_tcgplayer_export(self, db: Session, file_bytes: bytes, export_type: str = "live", use_cache: bool = False) -> dict:
|
||||||
|
"""
|
||||||
|
Process a TCGPlayer export file and load it into the inventory table.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
file_bytes: The downloaded file content as bytes
|
||||||
|
export_type: Type of export (staged, live, pricing)
|
||||||
|
use_cache: Whether to use cached export file for development
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Processing statistics
|
||||||
|
"""
|
||||||
|
stats = {
|
||||||
|
"total_rows": 0,
|
||||||
|
"processed_rows": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"error_messages": []
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# For development, use cached file if available
|
||||||
|
if use_cache:
|
||||||
|
cached_bytes = await self._load_cached_export(export_type)
|
||||||
|
if cached_bytes:
|
||||||
|
file_bytes = cached_bytes
|
||||||
|
else:
|
||||||
|
await self._cache_export(file_bytes, export_type)
|
||||||
|
|
||||||
|
# Convert bytes to string and create a file-like object
|
||||||
|
file_content = file_bytes.decode('utf-8')
|
||||||
|
file_like = io.StringIO(file_content)
|
||||||
|
|
||||||
|
# Read CSV file
|
||||||
|
csv_reader = csv.DictReader(file_like)
|
||||||
|
|
||||||
|
with transaction(db):
|
||||||
|
for row in csv_reader:
|
||||||
|
stats["total_rows"] += 1
|
||||||
|
try:
|
||||||
|
# Process each row and create/update inventory item in database
|
||||||
|
inventory_data = self._map_tcgplayer_row_to_inventory(row)
|
||||||
|
tcgplayer_id = inventory_data["tcgplayer_id"]
|
||||||
|
|
||||||
|
# Check if inventory item already exists
|
||||||
|
existing_item = self.inventory_service.get_by_tcgplayer_id(db, tcgplayer_id)
|
||||||
|
|
||||||
|
# Find matching TCGPlayer product
|
||||||
|
product_id = int(tcgplayer_id) if tcgplayer_id.isdigit() else None
|
||||||
|
if product_id:
|
||||||
|
tcg_product = db.query(TCGPlayerProduct).filter(TCGPlayerProduct.product_id == product_id).first()
|
||||||
|
if tcg_product:
|
||||||
|
# Update inventory data with product information if available
|
||||||
|
inventory_data.update({
|
||||||
|
"product_name": tcg_product.name,
|
||||||
|
"photo_url": tcg_product.image_url,
|
||||||
|
"rarity": tcg_product.ext_rarity,
|
||||||
|
"number": tcg_product.ext_number
|
||||||
|
})
|
||||||
|
|
||||||
|
if existing_item:
|
||||||
|
# Update existing item
|
||||||
|
self.inventory_service.update(db, existing_item, inventory_data)
|
||||||
|
else:
|
||||||
|
# Create new item
|
||||||
|
self.inventory_service.create(db, inventory_data)
|
||||||
|
|
||||||
|
stats["processed_rows"] += 1
|
||||||
|
except Exception as e:
|
||||||
|
stats["errors"] += 1
|
||||||
|
stats["error_messages"].append(f"Error processing row {stats['total_rows']}: {str(e)}")
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Failed to process TCGPlayer export: {str(e)}")
|
||||||
|
|
||||||
|
def _map_tcgplayer_row_to_inventory(self, row: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Map TCGPlayer export row to inventory model fields.
|
||||||
|
"""
|
||||||
|
def safe_float(value: str) -> float:
|
||||||
|
"""Convert string to float, returning 0.0 for empty strings or invalid values"""
|
||||||
|
try:
|
||||||
|
return float(value) if value else 0.0
|
||||||
|
except ValueError:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def safe_int(value: str) -> int:
|
||||||
|
"""Convert string to int, returning 0 for empty strings or invalid values"""
|
||||||
|
try:
|
||||||
|
return int(value) if value else 0
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tcgplayer_id": row.get("TCGplayer Id", ""),
|
||||||
|
"product_line": row.get("Product Line", ""),
|
||||||
|
"set_name": row.get("Set Name", ""),
|
||||||
|
"product_name": row.get("Product Name", ""),
|
||||||
|
"title": row.get("Title", ""),
|
||||||
|
"number": row.get("Number", ""),
|
||||||
|
"rarity": row.get("Rarity", ""),
|
||||||
|
"condition": row.get("Condition", ""),
|
||||||
|
"tcg_market_price": safe_float(row.get("TCG Market Price", "")),
|
||||||
|
"tcg_direct_low": safe_float(row.get("TCG Direct Low", "")),
|
||||||
|
"tcg_low_price_with_shipping": safe_float(row.get("TCG Low Price With Shipping", "")),
|
||||||
|
"tcg_low_price": safe_float(row.get("TCG Low Price", "")),
|
||||||
|
"total_quantity": safe_int(row.get("Total Quantity", "")),
|
||||||
|
"add_to_quantity": safe_int(row.get("Add to Quantity", "")),
|
||||||
|
"tcg_marketplace_price": safe_float(row.get("TCG Marketplace Price", "")),
|
||||||
|
"photo_url": row.get("Photo URL", "")
|
||||||
|
}
|
63
app/services/inventory_service.py
Normal file
63
app/services/inventory_service.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
from typing import List, Optional, Dict
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.models.inventory import Inventory
|
||||||
|
from app.services.base_service import BaseService
|
||||||
|
|
||||||
|
class InventoryService(BaseService[Inventory]):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(Inventory)
|
||||||
|
|
||||||
|
def create(self, db: Session, obj_in: Dict) -> Inventory:
|
||||||
|
"""
|
||||||
|
Create a new inventory item in the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
obj_in: Dictionary containing inventory data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Inventory: The created inventory object
|
||||||
|
"""
|
||||||
|
return super().create(db, obj_in)
|
||||||
|
|
||||||
|
def update(self, db: Session, db_obj: Inventory, obj_in: Dict) -> Inventory:
|
||||||
|
"""
|
||||||
|
Update an existing inventory item in the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
db_obj: The inventory object to update
|
||||||
|
obj_in: Dictionary containing updated inventory data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Inventory: The updated inventory object
|
||||||
|
"""
|
||||||
|
return super().update(db, db_obj, obj_in)
|
||||||
|
|
||||||
|
def get_by_tcgplayer_id(self, db: Session, tcgplayer_id: str) -> Optional[Inventory]:
|
||||||
|
"""
|
||||||
|
Get an inventory item by its TCGPlayer ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
tcgplayer_id: The TCGPlayer ID to find
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[Inventory]: The inventory item if found, None otherwise
|
||||||
|
"""
|
||||||
|
return db.query(self.model).filter(self.model.tcgplayer_id == tcgplayer_id).first()
|
||||||
|
|
||||||
|
def get_by_set(self, db: Session, set_name: str, skip: int = 0, limit: int = 100) -> List[Inventory]:
|
||||||
|
"""
|
||||||
|
Get all inventory items from a specific set.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
set_name: The name of the set to filter by
|
||||||
|
skip: Number of records to skip (for pagination)
|
||||||
|
limit: Maximum number of records to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[Inventory]: List of inventory items from the specified set
|
||||||
|
"""
|
||||||
|
return db.query(self.model).filter(self.model.set_name == set_name).offset(skip).limit(limit).all()
|
58
app/services/order_service.py
Normal file
58
app/services/order_service.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.services.base_service import BaseService
|
||||||
|
from app.models.order import Order, OrderCard
|
||||||
|
from app.models.card import Card
|
||||||
|
|
||||||
|
class OrderService(BaseService):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(Order)
|
||||||
|
|
||||||
|
def create_order_with_cards(self, db: Session, order_data: dict, card_ids: list[int]) -> Order:
|
||||||
|
"""
|
||||||
|
Create a new order with associated cards.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
order_data: Dictionary containing order details
|
||||||
|
card_ids: List of card IDs to associate with the order
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created Order object
|
||||||
|
"""
|
||||||
|
# Create the order
|
||||||
|
order = Order(**order_data)
|
||||||
|
db.add(order)
|
||||||
|
db.flush() # Get the order ID
|
||||||
|
|
||||||
|
# Associate cards with the order
|
||||||
|
for card_id in card_ids:
|
||||||
|
card = db.query(Card).filter(Card.id == card_id).first()
|
||||||
|
if not card:
|
||||||
|
raise ValueError(f"Card with ID {card_id} not found")
|
||||||
|
|
||||||
|
order_card = OrderCard(order_id=order.id, card_id=card_id)
|
||||||
|
db.add(order_card)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(order)
|
||||||
|
return order
|
||||||
|
|
||||||
|
def get_orders_with_cards(self, db: Session, skip: int = 0, limit: int = 10) -> list[Order]:
|
||||||
|
"""
|
||||||
|
Get orders with their associated cards.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
skip: Number of records to skip
|
||||||
|
limit: Maximum number of records to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Order objects with their associated cards
|
||||||
|
"""
|
||||||
|
orders = db.query(Order).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
# Eager load the cards for each order
|
||||||
|
for order in orders:
|
||||||
|
order.cards = db.query(Card).join(OrderCard).filter(OrderCard.order_id == order.id).all()
|
||||||
|
|
||||||
|
return orders
|
54
app/services/scheduler/scheduler_service.py
Normal file
54
app/services/scheduler/scheduler_service.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.db.database import SessionLocal, transaction
|
||||||
|
from app.services.external_api.tcgplayer.tcgplayer_inventory_service import TCGPlayerInventoryService
|
||||||
|
from app.services.file_processing_service import FileProcessingService
|
||||||
|
from app.services.scheduler.base_scheduler import BaseScheduler
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class SchedulerService:
|
||||||
|
def __init__(self):
|
||||||
|
self.tcgplayer_service = TCGPlayerInventoryService()
|
||||||
|
self.file_processor = FileProcessingService()
|
||||||
|
self.scheduler = BaseScheduler()
|
||||||
|
|
||||||
|
async def process_tcgplayer_export(self, export_type: str = "live", use_cache: bool = False):
|
||||||
|
"""
|
||||||
|
Process TCGPlayer export as a scheduled task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
export_type: Type of export to process (staged, live, or pricing)
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
logger.info(f"Starting scheduled TCGPlayer export processing for {export_type}")
|
||||||
|
|
||||||
|
# Download the file
|
||||||
|
file_bytes = await self.tcgplayer_service.get_tcgplayer_export(export_type)
|
||||||
|
|
||||||
|
# Process the file and load into database
|
||||||
|
with transaction(db):
|
||||||
|
stats = await self.file_processor.process_tcgplayer_export(db, export_type=export_type, file_bytes=file_bytes, use_cache=use_cache)
|
||||||
|
|
||||||
|
logger.info(f"Completed TCGPlayer export processing: {stats}")
|
||||||
|
return stats
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing TCGPlayer export: {str(e)}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
async def start_scheduled_tasks(self):
|
||||||
|
"""Start all scheduled tasks"""
|
||||||
|
# Schedule TCGPlayer export processing to run daily at 2 AM
|
||||||
|
await self.scheduler.schedule_task(
|
||||||
|
task_name="process_tcgplayer_export",
|
||||||
|
func=self.process_tcgplayer_export,
|
||||||
|
interval_seconds=24 * 60 * 60, # 24 hours
|
||||||
|
export_type="live"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.scheduler.start()
|
||||||
|
logger.info("All scheduled tasks started")
|
34
requirements.txt
Normal file
34
requirements.txt
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
aiohappyeyeballs==2.6.1
|
||||||
|
aiohttp==3.11.16
|
||||||
|
aiosignal==1.3.2
|
||||||
|
alembic==1.13.1
|
||||||
|
annotated-types==0.7.0
|
||||||
|
anyio==4.9.0
|
||||||
|
APScheduler==3.10.4
|
||||||
|
attrs==25.3.0
|
||||||
|
blinker==1.9.0
|
||||||
|
click==8.1.8
|
||||||
|
fastapi==0.115.12
|
||||||
|
Flask==3.1.0
|
||||||
|
frozenlist==1.5.0
|
||||||
|
h11==0.14.0
|
||||||
|
idna==3.10
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
Jinja2==3.1.6
|
||||||
|
Mako==1.3.9
|
||||||
|
MarkupSafe==3.0.2
|
||||||
|
multidict==6.4.2
|
||||||
|
propcache==0.3.1
|
||||||
|
pydantic==2.11.3
|
||||||
|
pydantic_core==2.33.1
|
||||||
|
pytz==2025.2
|
||||||
|
six==1.17.0
|
||||||
|
sniffio==1.3.1
|
||||||
|
SQLAlchemy==2.0.40
|
||||||
|
starlette==0.46.1
|
||||||
|
typing-inspection==0.4.0
|
||||||
|
typing_extensions==4.13.1
|
||||||
|
tzlocal==5.3.1
|
||||||
|
uvicorn==0.34.0
|
||||||
|
Werkzeug==3.1.3
|
||||||
|
yarl==1.19.0
|
Loading…
x
Reference in New Issue
Block a user