Compare commits
94 Commits
Author | SHA1 | Date | |
---|---|---|---|
fb3134723a | |||
8155ba2f8d | |||
a97ba6e858 | |||
3939c21a72 | |||
9ff87dc107 | |||
de3821aa80 | |||
e67c1aa9f3 | |||
94a3a517c7 | |||
64897392f1 | |||
3199a3259f | |||
0e5ba991db | |||
3601bcc81b | |||
c234285788 | |||
3ec9fef3cf | |||
9e656ef329 | |||
cc829c3b54 | |||
105c02a3de | |||
4f56eec551 | |||
029c28166e | |||
1bb842ea3f | |||
9603a7e58f | |||
3dc955698e | |||
eadff66eb6 | |||
6ded276269 | |||
168313d882 | |||
57ac76386b | |||
a1f15d6e6a | |||
11ed680347 | |||
fff6007a10 | |||
846a44e5fc | |||
9360bc9f9a | |||
48fa6bfaa9 | |||
c151d41c43 | |||
9021151b74 | |||
f59f0f350c | |||
478ce4ec41 | |||
18ceef8351 | |||
87c84fd0a8 | |||
0c78276b12 | |||
47a1b1d3ac | |||
c889a84c34 | |||
1ef706afe5 | |||
3454f24451 | |||
7786655db0 | |||
ed52e7da04 | |||
bf3f4ddb38 | |||
3f53513c36 | |||
d3bd696d67 | |||
113a920da7 | |||
9d11adaf6c | |||
8de5bec523 | |||
0414018099 | |||
d4579a9db0 | |||
544f789e2e | |||
c1ab5d611f | |||
c9b57f00ea | |||
45c589d225 | |||
60eee07249 | |||
3d95553cbd | |||
2d01dae9ed | |||
1983f19fa7 | |||
8a26abc5f4 | |||
d76258eb55 | |||
86498d54b4 | |||
2800135375 | |||
c3b4fe28d2 | |||
d4ffe180a3 | |||
8b0396ba00 | |||
99dfc3f6f8 | |||
8a4ec31bee | |||
cafc77d07f | |||
dff5bc4a23 | |||
8097fba83c | |||
da492180b4 | |||
e13b871fda | |||
ac6397de01 | |||
4c6d256316 | |||
1bf255d0fe | |||
721b26ce97 | |||
92d1356c0e | |||
3f8a99b61a | |||
012bb40a04 | |||
85329c232c | |||
7c29e2d8d7 | |||
cc315129b9 | |||
511d4dbcee | |||
af0e789ec9 | |||
fbd6dd5752 | |||
aa1cdc2fb3 | |||
c7686fb239 | |||
c896a6ea0f | |||
edf76708b3 | |||
c10c3a0beb | |||
cc365970a9 |
32
.gitea/workflows/deploy.yaml
Normal file
32
.gitea/workflows/deploy.yaml
Normal file
@ -0,0 +1,32 @@
|
||||
# .gitea/workflows/deploy.yml
|
||||
name: Deploy App to Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Docker
|
||||
uses: docker/setup-buildx-action@v1
|
||||
with:
|
||||
version: 'latest'
|
||||
|
||||
- name: Build Docker Image
|
||||
run: |
|
||||
docker build -t giga_tcg .
|
||||
|
||||
- name: Remove existing Docker container
|
||||
run: |
|
||||
docker rm -f giga_tcg_container || true
|
||||
|
||||
- name: Run Docker container
|
||||
run: |
|
||||
docker run -d -v /mnt/user/appdata/gigatcg/giga_tcg/cookies:/app/cookies -v /mnt/user/appdata/gigatcg/tmp:/app/tmp -p 8000:8000 --name giga_tcg_container giga_tcg
|
8
.gitignore
vendored
8
.gitignore
vendored
@ -169,4 +169,10 @@ cython_debug/
|
||||
#.idea/
|
||||
|
||||
# my stuff
|
||||
*.db
|
||||
*.db
|
||||
temp/
|
||||
.DS_Store
|
||||
*.db-journal
|
||||
cookies/
|
||||
alembic/versions/*
|
||||
*.csv
|
29
Dockerfile
Normal file
29
Dockerfile
Normal file
@ -0,0 +1,29 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV DATABASE_URL=postgresql://poggers:giga!@192.168.1.41:5432/omegatcgdb
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libglib2.0-0 \
|
||||
libpango-1.0-0 \
|
||||
libcairo2 \
|
||||
libjpeg62-turbo \
|
||||
libgif7 \
|
||||
libxml2 \
|
||||
fonts-liberation \
|
||||
libharfbuzz0b \
|
||||
libfribidi0 \
|
||||
libgtk-3-0 \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
119
alembic.ini
Normal file
119
alembic.ini
Normal file
@ -0,0 +1,119 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
# Use forward slashes (/) also on windows to provide an os agnostic path
|
||||
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>=3.9 or backports.zoneinfo library and tzdata library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# 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 commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
# version_path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
version_path_separator = os
|
||||
|
||||
# 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:///omegacard.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/.venv/bin/ruff
|
||||
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
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.
|
90
alembic/env.py
Normal file
90
alembic/env.py
Normal file
@ -0,0 +1,90 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
from app.db.models import Base
|
||||
from app.db.database import DATABASE_URL
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
config.set_main_option('sqlalchemy.url', DATABASE_URL)
|
||||
|
||||
|
||||
|
||||
# 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
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
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"}
|
30
alembic/versions/f629adc7e597_.py
Normal file
30
alembic/versions/f629adc7e597_.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: f629adc7e597
|
||||
Revises:
|
||||
Create Date: 2025-02-07 20:13:32.559672
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'f629adc7e597'
|
||||
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! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
BIN
app/assets/images/ccrcardsaddress.png
Normal file
BIN
app/assets/images/ccrcardsaddress.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
87
app/assets/templates/address_label.html
Normal file
87
app/assets/templates/address_label.html
Normal file
@ -0,0 +1,87 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
/* Setting up the page size for landscape orientation - 6x4 inches */
|
||||
@page {
|
||||
size: 6in 4in; /* Landscape orientation */
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Force page breaks after each label */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 6in; /* Adjusted for landscape */
|
||||
height: 4in; /* Adjusted for landscape */
|
||||
position: relative;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.label-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding: 0.25in;
|
||||
page-break-after: always; /* Ensures a page break after each label */
|
||||
}
|
||||
|
||||
/* Return address image in top left */
|
||||
.return-address {
|
||||
position: absolute;
|
||||
top: 0.25in;
|
||||
left: 0.25in;
|
||||
width: 2.75in;
|
||||
height: 0.85in;
|
||||
}
|
||||
|
||||
/* Stamp area in top right */
|
||||
.stamp-area {
|
||||
position: absolute;
|
||||
top: 0.25in;
|
||||
right: 0.25in;
|
||||
width: 0.8in;
|
||||
height: 0.9in;
|
||||
border: 1px dashed #999;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stamp-text {
|
||||
font-size: 8pt;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Main address centered in the middle */
|
||||
.address {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 3in;
|
||||
text-align: center;
|
||||
font-size: 12pt;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="label-container">
|
||||
<img src="{{ return_address_path }}" class="return-address" alt="Return Address">
|
||||
|
||||
<div class="stamp-area">
|
||||
<span class="stamp-text">PLACE<br>STAMP<br>HERE</span>
|
||||
</div>
|
||||
|
||||
<div class="address">
|
||||
{{ recipient_name }}<br>
|
||||
{{ address_line1 }}<br>
|
||||
{% if address_line2 %}{{ address_line2 }}<br>{% endif %}
|
||||
{{ city }}, {{ state }} {{ zip_code }}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
@ -3,6 +3,12 @@ from sqlalchemy.orm import sessionmaker, Session
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator
|
||||
import os
|
||||
from sqlalchemy import inspect
|
||||
from app.services.tcgplayer import TCGPlayerService
|
||||
from app.services.pricing import PricingService
|
||||
from app.services.file import FileService
|
||||
from app.db.models import Price
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
import logging
|
||||
@ -44,12 +50,47 @@ def get_db() -> Generator[Session, None, None]:
|
||||
with get_db_session() as session:
|
||||
yield session
|
||||
|
||||
def prepopulate_data(db: Session, db_exist: bool = False) -> None:
|
||||
file_service = FileService(db)
|
||||
tcgplayer_service = TCGPlayerService(db, file_service)
|
||||
pricing_service = PricingService(db, file_service, tcgplayer_service)
|
||||
if not db_exist:
|
||||
tcgplayer_service.populate_tcgplayer_groups()
|
||||
file = tcgplayer_service.load_tcgplayer_cards()
|
||||
pricing_service.cron_load_prices(file)
|
||||
else:
|
||||
pricing_service.cron_load_prices()
|
||||
|
||||
def init_db() -> None:
|
||||
"""Initialize database tables"""
|
||||
"""Initialize database tables and run first-time setup if needed"""
|
||||
from .models import Base
|
||||
try:
|
||||
inspector = inspect(engine)
|
||||
tables_exist = all(
|
||||
table in inspector.get_table_names()
|
||||
for table in Base.metadata.tables.keys()
|
||||
)
|
||||
if tables_exist:
|
||||
with get_db_session() as db:
|
||||
# get date created of latest pricing record
|
||||
latest_price = db.query(Price).order_by(Price.date_created.desc()).first()
|
||||
if latest_price:
|
||||
# check if it is greater than 1.5 hours old
|
||||
if (datetime.now() - latest_price.date_created).total_seconds() > 5400:
|
||||
prepopulate_data(db, db_exist=True)
|
||||
else:
|
||||
prepopulate_data(db, db_exist=True)
|
||||
|
||||
# Create tables if they don't exist
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("Database tables created successfully")
|
||||
|
||||
# Run first-time setup only if tables were just created
|
||||
if not tables_exist:
|
||||
with get_db_session() as db:
|
||||
prepopulate_data(db)
|
||||
logger.info("First-time database setup completed")
|
||||
|
||||
logger.info("Database initialization completed")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize database: {str(e)}")
|
||||
raise
|
434
app/db/models.py
Normal file
434
app/db/models.py
Normal file
@ -0,0 +1,434 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship, validates
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
## Core Models
|
||||
|
||||
class Product(Base):
|
||||
"""
|
||||
product is the concept of a physical item that can be sold
|
||||
"""
|
||||
__tablename__ = "products"
|
||||
|
||||
@validates("type")
|
||||
def validate_type(self, key, type: str):
|
||||
if type not in ProductTypeEnum or type.lower() not in ProductTypeEnum:
|
||||
raise ValueError(f"Invalid product type: {type}")
|
||||
return type
|
||||
|
||||
@validates("product_line")
|
||||
def validate_product_line(self, key, product_line: str):
|
||||
if product_line not in ProductLineEnum or product_line.lower() not in ProductLineEnum:
|
||||
raise ValueError(f"Invalid product line: {product_line}")
|
||||
return product_line
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
type = Column(String) # box or card
|
||||
product_line = Column(String) # pokemon, mtg, etc.
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class Sale(Base):
|
||||
"""
|
||||
sale represents a transaction where a product was sold to a customer on a marketplace
|
||||
"""
|
||||
__tablename__ = "sales"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
ledger_id = Column(String, ForeignKey("ledgers.id"))
|
||||
customer_id = Column(String, ForeignKey("customers.id"))
|
||||
marketplace_id = Column(String, ForeignKey("marketplaces.id"))
|
||||
amount = Column(Float)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class Ledger(Base):
|
||||
"""
|
||||
ledger associates financial transactions with a user
|
||||
"""
|
||||
__tablename__ = "ledgers"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
user_id = Column(String, ForeignKey("users.id"))
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class Expense(Base):
|
||||
"""
|
||||
expense is any cash outflow associated with moving a product
|
||||
can be optionally associated with a sale or a product
|
||||
"""
|
||||
__tablename__ = "expenses"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
ledger_id = Column(String, ForeignKey("ledgers.id"))
|
||||
product_id = Column(String, ForeignKey("products.id"), nullable=True)
|
||||
sale_id = Column(String, ForeignKey("sales.id"), nullable=True)
|
||||
cost = Column(Float)
|
||||
type = Column(String) # price paid, cogs, shipping, refund, supplies, subscription, fee, etc.
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class Marketplace(Base):
|
||||
"""
|
||||
Marketplace represents a marketplace where products can be sold
|
||||
"""
|
||||
__tablename__ = "marketplaces"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class Box(Base):
|
||||
"""
|
||||
Box Represents a physical product with a sku that contains trading cards
|
||||
Boxes can be sealed or opened
|
||||
Opened boxes have cards associated with them
|
||||
A box contains cards regardless of the inventory status of those cards
|
||||
"""
|
||||
__tablename__ = "boxes"
|
||||
|
||||
@validates("type")
|
||||
def validate_type(self, key, type: str):
|
||||
if type not in BoxTypeEnum or type.lower() not in BoxTypeEnum:
|
||||
raise ValueError(f"Invalid box type: {type}")
|
||||
return type
|
||||
|
||||
product_id = Column(String, ForeignKey("products.id"), primary_key=True)
|
||||
type = Column(String) # collector box, play box, etc.
|
||||
set_code = Column(String)
|
||||
sku = Column(String, nullable=True)
|
||||
num_cards_expected = Column(Integer, nullable=True)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class OpenBox(Base):
|
||||
__tablename__ = "open_boxes"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
product_id = Column(String, ForeignKey("products.id"))
|
||||
num_cards_actual = Column(Integer)
|
||||
date_opened = Column(DateTime, default=datetime.now)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class Card(Base):
|
||||
"""
|
||||
Card represents the concept of a distinct card
|
||||
Cards have metadata from different sources
|
||||
"""
|
||||
__tablename__ = "cards"
|
||||
|
||||
product_id = Column(String, ForeignKey("products.id"), primary_key=True)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class CardManabox(Base):
|
||||
__tablename__ = "manabox_cards"
|
||||
|
||||
product_id = Column(String, ForeignKey("cards.product_id"), primary_key=True)
|
||||
name = Column(String)
|
||||
set_code = Column(String)
|
||||
set_name = Column(String)
|
||||
collector_number = Column(String)
|
||||
foil = Column(String)
|
||||
rarity = Column(String)
|
||||
manabox_id = Column(Integer)
|
||||
scryfall_id = Column(String)
|
||||
condition = Column(String)
|
||||
language = Column(String)
|
||||
|
||||
class CardTCGPlayer(Base):
|
||||
__tablename__ = "tcgplayer_cards"
|
||||
|
||||
product_id = Column(String, ForeignKey("cards.product_id"), primary_key=True)
|
||||
group_id = Column(Integer)
|
||||
tcgplayer_id = Column(Integer)
|
||||
product_line = Column(String)
|
||||
set_name = Column(String)
|
||||
product_name = Column(String)
|
||||
title = Column(String)
|
||||
number = Column(String)
|
||||
rarity = Column(String)
|
||||
condition = Column(String)
|
||||
|
||||
class Warehouse(Base):
|
||||
"""
|
||||
container that is associated with a user and contains inventory and stock
|
||||
"""
|
||||
__tablename__ = "warehouses"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
user_id = Column(String, ForeignKey("users.id"))
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class Stock(Base):
|
||||
"""
|
||||
contains products that are listed for sale
|
||||
"""
|
||||
__tablename__ = "stocks"
|
||||
|
||||
product_id = Column(String, ForeignKey("products.id"), primary_key=True)
|
||||
warehouse_id = Column(String, ForeignKey("warehouses.id"), default="default")
|
||||
marketplace_id = Column(String, ForeignKey("marketplaces.id"))
|
||||
quantity = Column(Integer)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class Inventory(Base):
|
||||
"""
|
||||
contains products in inventory (not necessarily listed for sale)
|
||||
sealed product in breakdown queue, held sealed product, speculatively held singles, etc.
|
||||
inventory can contain products across multiple marketplaces
|
||||
"""
|
||||
__tablename__ = "inventories"
|
||||
|
||||
product_id = Column(String, ForeignKey("products.id"), primary_key=True)
|
||||
warehouse_id = Column(String, ForeignKey("warehouses.id"), default="default")
|
||||
quantity = Column(Integer)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class User(Base):
|
||||
"""
|
||||
User represents a user in the system
|
||||
"""
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
username = Column(String)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class Customer(Base):
|
||||
"""
|
||||
Customer represents a customer that has purchased at least 1 product
|
||||
"""
|
||||
__tablename__ = "customers"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class StagedFileProduct(Base):
|
||||
__tablename__ = "staged_file_products"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
product_id = Column(String, ForeignKey("products.id"))
|
||||
file_id = Column(String, ForeignKey("files.id"))
|
||||
quantity = Column(Integer)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class File(Base):
|
||||
"""
|
||||
File represents a file that has been uploaded to or retrieved by the system
|
||||
"""
|
||||
__tablename__ = "files"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
type = Column(String) # upload, export, etc.
|
||||
source = Column(String) # manabox, tcgplayer, etc.
|
||||
service = Column(String) # pricing, data, etc.
|
||||
filename = Column(String)
|
||||
filepath = Column(String) # backup location
|
||||
filesize_kb = Column(Float)
|
||||
status = Column(String)
|
||||
box_id = Column(String, nullable=True)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class Price(Base):
|
||||
__tablename__ = "prices"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
product_id = Column(String, ForeignKey("products.id"))
|
||||
marketplace_id = Column(String, ForeignKey("marketplaces.id"))
|
||||
type = Column(String) # market, direct, low, low_with_shipping, marketplace
|
||||
price = Column(Float)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class StorageBlock(Base):
|
||||
"""
|
||||
StorageBlock represents a physical storage location for products (50 card indexed block in a box)
|
||||
"""
|
||||
__tablename__ = "storage_blocks"
|
||||
|
||||
@validates("type")
|
||||
def validate_type(self, key, type: str):
|
||||
if type not in StorageBlockTypeEnum or type.lower() not in StorageBlockTypeEnum:
|
||||
raise ValueError(f"Invalid storage block type: {type}")
|
||||
return type
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
warehouse_id = Column(String, ForeignKey("warehouses.id"))
|
||||
name = Column(String)
|
||||
type = Column(String) # rare or common
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class ProductBlock(Base):
|
||||
"""
|
||||
ProductBlock represents the relationship between a product and a storage block
|
||||
which products are in a block and at what index
|
||||
"""
|
||||
__tablename__ = "product_blocks"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
product_id = Column(String, ForeignKey("products.id"))
|
||||
block_id = Column(String, ForeignKey("storage_blocks.id"))
|
||||
block_index = Column(Integer)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class OpenBoxCard(Base):
|
||||
"""
|
||||
OpenedBoxCard represents the relationship between an opened box and the cards it contains
|
||||
"""
|
||||
__tablename__ = "open_box_cards"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
open_box_id = Column(String, ForeignKey("open_boxes.id"))
|
||||
card_id = Column(String, ForeignKey("cards.product_id"))
|
||||
quantity = Column(Integer)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class ProductSale(Base):
|
||||
"""
|
||||
ProductSale represents the relationship between products and sales
|
||||
"""
|
||||
__tablename__ = "product_sales"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
product_id = Column(String, ForeignKey("products.id"))
|
||||
sale_id = Column(String, ForeignKey("sales.id"))
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class TCGPlayerGroups(Base):
|
||||
__tablename__ = 'tcgplayer_groups'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
group_id = Column(Integer)
|
||||
name = Column(String)
|
||||
abbreviation = Column(String)
|
||||
is_supplemental = Column(String)
|
||||
published_on = Column(String)
|
||||
modified_on = Column(String)
|
||||
category_id = Column(Integer)
|
||||
|
||||
class Orders(Base):
|
||||
__tablename__ = 'orders'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
order_id = Column(String, unique=True)
|
||||
buyer_name = Column(String)
|
||||
recipient_name = Column(String)
|
||||
recipient_address_one = Column(String)
|
||||
recipient_address_two = Column(String)
|
||||
recipient_city = Column(String)
|
||||
recipient_state = Column(String)
|
||||
recipient_zip = Column(String)
|
||||
recipient_country = Column(String)
|
||||
order_date = Column(String)
|
||||
status = Column(String)
|
||||
num_products = Column(Integer)
|
||||
num_cards = Column(Integer)
|
||||
product_amount = Column(Float)
|
||||
shipping_amount = Column(Float)
|
||||
gross_amount = Column(Float)
|
||||
fee_amount = Column(Float)
|
||||
net_amount = Column(Float)
|
||||
direct_fee_amount = Column(Float)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class OrderProducts(Base):
|
||||
__tablename__ = 'order_products'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
order_id = Column(String, ForeignKey('orders.id'))
|
||||
product_id = Column(String, ForeignKey('products.id'))
|
||||
quantity = Column(Integer)
|
||||
unit_price = Column(Float)
|
||||
|
||||
class APIPricing(Base):
|
||||
__tablename__ = 'api_pricing'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
product_id = Column(String, ForeignKey('products.id'))
|
||||
pricing_data = Column(String)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class TCGPlayerInventory(Base):
|
||||
__tablename__ = 'tcgplayer_inventory'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
tcgplayer_id = Column(Integer)
|
||||
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)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# enums
|
||||
|
||||
class RarityEnum(str, Enum):
|
||||
COMMON = "common"
|
||||
UNCOMMON = "uncommon"
|
||||
RARE = "rare"
|
||||
MYTHIC = "mythic"
|
||||
LAND = "land"
|
||||
PROMO = "promo"
|
||||
SPECIAL = "special"
|
||||
|
||||
class ConditionEnum(str, Enum):
|
||||
MINT = "mint"
|
||||
NEAR_MINT = "near_mint"
|
||||
LIGHTLY_PLAYED = "lightly_played"
|
||||
MODERATELY_PLAYED = "moderately_played"
|
||||
HEAVILY_PLAYED = "heavily_played"
|
||||
DAMAGED = "damaged"
|
||||
|
||||
class BoxTypeEnum(str, Enum):
|
||||
COLLECTOR = "collector"
|
||||
PLAY = "play"
|
||||
DRAFT = "draft"
|
||||
COMMANDER = "commander"
|
||||
SET = "set"
|
||||
|
||||
class ProductLineEnum(str, Enum):
|
||||
MTG = "mtg"
|
||||
POKEMON = "pokemon"
|
||||
|
||||
class ProductTypeEnum(str, Enum):
|
||||
BOX = "box"
|
||||
CARD = "card"
|
||||
|
||||
class StorageBlockTypeEnum(str, Enum):
|
||||
RARE = "rare"
|
||||
COMMON = "common"
|
@ -1,6 +1,6 @@
|
||||
from contextlib import contextmanager
|
||||
from sqlalchemy.orm import Session
|
||||
from exceptions import FailedUploadException
|
||||
from app.exceptions import FailedUploadException
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
130
app/dependencies.py
Normal file
130
app/dependencies.py
Normal file
@ -0,0 +1,130 @@
|
||||
from typing import Annotated
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import Depends, Form
|
||||
|
||||
from app.services.box import BoxService
|
||||
from app.services.tcgplayer import TCGPlayerService
|
||||
from app.services.pricing import PricingService
|
||||
from app.services.file import FileService
|
||||
from app.services.product import ProductService
|
||||
from app.services.inventory import InventoryService
|
||||
from app.services.task import TaskService
|
||||
from app.services.storage import StorageService
|
||||
from app.services.tcgplayer_api import TCGPlayerAPIService
|
||||
from app.db.database import get_db
|
||||
from app.schemas.file import CreateFileRequest
|
||||
from app.schemas.box import CreateBoxRequest, UpdateBoxRequest, CreateOpenBoxRequest
|
||||
|
||||
# Common type annotation for database dependency
|
||||
DB = Annotated[Session, Depends(get_db)]
|
||||
|
||||
# Base Services (no dependencies besides DB)
|
||||
def get_tcgplayer_api_service(db: DB) -> TCGPlayerAPIService:
|
||||
"""TCGPlayerAPIService with only database dependency"""
|
||||
return TCGPlayerAPIService(db)
|
||||
|
||||
def get_file_service(db: DB) -> FileService:
|
||||
"""FileService with only database dependency"""
|
||||
return FileService(db)
|
||||
|
||||
def get_storage_service(db: DB) -> StorageService:
|
||||
"""StorageService with only database dependency"""
|
||||
return StorageService(db)
|
||||
|
||||
def get_inventory_service(db: DB) -> InventoryService:
|
||||
"""InventoryService with only database dependency"""
|
||||
return InventoryService(db)
|
||||
|
||||
# Services with dependencies on other services
|
||||
def get_tcgplayer_service(
|
||||
db: DB,
|
||||
file_service: Annotated[FileService, Depends(get_file_service)]
|
||||
) -> TCGPlayerService:
|
||||
"""TCGPlayerService depends on PricingService"""
|
||||
return TCGPlayerService(db, file_service)
|
||||
|
||||
def get_pricing_service(db: DB, file_service: Annotated[FileService, Depends(get_file_service)], tcgplayer_service: Annotated[TCGPlayerService, Depends(get_tcgplayer_service)]) -> PricingService:
|
||||
"""PricingService with only database dependency"""
|
||||
return PricingService(db, file_service, tcgplayer_service)
|
||||
|
||||
def get_product_service(
|
||||
db: DB,
|
||||
file_service: Annotated[FileService, Depends(get_file_service)],
|
||||
tcgplayer_service: Annotated[TCGPlayerService, Depends(get_tcgplayer_service)],
|
||||
storage_service: Annotated[StorageService, Depends(get_storage_service)]
|
||||
) -> ProductService:
|
||||
"""ProductService with multiple service dependencies"""
|
||||
return ProductService(db, file_service, tcgplayer_service, storage_service)
|
||||
|
||||
def get_box_service(
|
||||
db: DB,
|
||||
inventory_service: Annotated[InventoryService, Depends(get_inventory_service)]
|
||||
) -> BoxService:
|
||||
"""BoxService depends on InventoryService"""
|
||||
return BoxService(db, inventory_service)
|
||||
|
||||
def get_task_service(
|
||||
db: DB,
|
||||
product_service: Annotated[ProductService, Depends(get_product_service)],
|
||||
pricing_service: Annotated[PricingService, Depends(get_pricing_service)],
|
||||
tcgplayer_api_service: Annotated[TCGPlayerAPIService, Depends(get_tcgplayer_api_service)]
|
||||
) -> TaskService:
|
||||
"""TaskService depends on ProductService and TCGPlayerService"""
|
||||
return TaskService(db, product_service, pricing_service, tcgplayer_api_service)
|
||||
|
||||
# Form data dependencies
|
||||
def get_create_file_metadata(
|
||||
type: str = Form(...),
|
||||
source: str = Form(...),
|
||||
service: str = Form(None),
|
||||
filename: str = Form(None)
|
||||
) -> CreateFileRequest:
|
||||
"""Form dependency for file creation"""
|
||||
return CreateFileRequest(
|
||||
type=type,
|
||||
source=source,
|
||||
service=service,
|
||||
filename=filename
|
||||
)
|
||||
|
||||
def get_box_data(
|
||||
type: str = Form(...),
|
||||
sku: str = Form(None),
|
||||
set_code: str = Form(...),
|
||||
num_cards_expected: int = Form(None)
|
||||
) -> CreateBoxRequest:
|
||||
"""Form dependency for box creation"""
|
||||
return CreateBoxRequest(
|
||||
type=type,
|
||||
sku=sku,
|
||||
set_code=set_code,
|
||||
num_cards_expected=num_cards_expected
|
||||
)
|
||||
|
||||
def get_box_update_data(
|
||||
type: str = Form(None),
|
||||
sku: str = Form(None),
|
||||
set_code: str = Form(None),
|
||||
num_cards_expected: int = Form(None)
|
||||
) -> UpdateBoxRequest:
|
||||
"""Form dependency for box updates"""
|
||||
return UpdateBoxRequest(
|
||||
type=type,
|
||||
sku=sku,
|
||||
set_code=set_code,
|
||||
num_cards_expected=num_cards_expected
|
||||
)
|
||||
|
||||
def get_open_box_data(
|
||||
product_id: str = Form(...),
|
||||
file_ids: list[str] = Form(None),
|
||||
num_cards_actual: int = Form(None),
|
||||
date_opened: str = Form(None)
|
||||
) -> CreateOpenBoxRequest:
|
||||
"""Form dependency for opening boxes"""
|
||||
return CreateOpenBoxRequest(
|
||||
product_id=product_id,
|
||||
file_ids=file_ids,
|
||||
num_cards_actual=num_cards_actual,
|
||||
date_opened=date_opened
|
||||
)
|
95
app/main.py
Normal file
95
app/main.py
Normal file
@ -0,0 +1,95 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import uvicorn
|
||||
from app.routes.routes import router
|
||||
from app.db.database import init_db, check_db_connection, get_db
|
||||
import logging
|
||||
import sys
|
||||
|
||||
# Import your dependency functions
|
||||
from app.dependencies import (
|
||||
get_task_service,
|
||||
get_tcgplayer_service,
|
||||
get_pricing_service,
|
||||
get_file_service,
|
||||
get_product_service,
|
||||
get_storage_service,
|
||||
get_inventory_service,
|
||||
get_tcgplayer_api_service
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler('app.log')
|
||||
]
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Create FastAPI instance
|
||||
app = FastAPI(
|
||||
title="Card Management API",
|
||||
description="API for managing card collections and TCGPlayer integration",
|
||||
version="1.0.0",
|
||||
debug=True
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Modify this in production
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(router)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
try:
|
||||
# Check database connection
|
||||
if not check_db_connection():
|
||||
logger.error("Database connection failed")
|
||||
raise Exception("Database connection failed")
|
||||
|
||||
# Initialize database
|
||||
init_db()
|
||||
|
||||
# Get database session
|
||||
db = next(get_db())
|
||||
|
||||
# Use dependency injection to get services
|
||||
file_service = get_file_service(db)
|
||||
storage_service = get_storage_service(db)
|
||||
inventory_service = get_inventory_service(db)
|
||||
tcgplayer_service = get_tcgplayer_service(db, file_service)
|
||||
pricing_service = get_pricing_service(db, file_service, tcgplayer_service)
|
||||
product_service = get_product_service(db, file_service, tcgplayer_service, storage_service)
|
||||
tcgplayer_api_service = get_tcgplayer_api_service(db)
|
||||
task_service = get_task_service(db, product_service, pricing_service, tcgplayer_api_service)
|
||||
|
||||
# Start task service
|
||||
await task_service.start()
|
||||
|
||||
logger.info("Application started successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Startup failed: {str(e)}")
|
||||
raise
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
logger.info("Application shutting down")
|
||||
pass
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "Card Management API"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
339
app/routes/routes.py
Normal file
339
app/routes/routes.py
Normal file
@ -0,0 +1,339 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, BackgroundTasks, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from typing import Optional, List
|
||||
from io import BytesIO
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import os
|
||||
import json
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.file import (
|
||||
FileSchema,
|
||||
CreateFileRequest,
|
||||
CreateFileResponse,
|
||||
GetFileResponse,
|
||||
DeleteFileResponse,
|
||||
GetFileQueryParams
|
||||
)
|
||||
from app.schemas.box import (
|
||||
CreateBoxResponse,
|
||||
CreateBoxRequest,
|
||||
BoxSchema,
|
||||
UpdateBoxRequest,
|
||||
CreateOpenBoxRequest,
|
||||
CreateOpenBoxResponse,
|
||||
OpenBoxSchema
|
||||
)
|
||||
from app.schemas.orders import ProcessOrdersResponse
|
||||
from app.services.file import FileService
|
||||
from app.services.box import BoxService
|
||||
from app.services.task import TaskService
|
||||
from app.services.pricing import PricingService
|
||||
from app.services.tcgplayer_api import TCGPlayerAPIService
|
||||
from app.dependencies import (
|
||||
get_file_service,
|
||||
get_box_service,
|
||||
get_task_service,
|
||||
get_create_file_metadata,
|
||||
get_box_data,
|
||||
get_box_update_data,
|
||||
get_open_box_data,
|
||||
get_pricing_service,
|
||||
get_tcgplayer_api_service
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["cards"])
|
||||
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100 MB
|
||||
|
||||
async def validate_file_upload(file: UploadFile) -> bytes:
|
||||
"""Validate uploaded file and return its contents."""
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="No filename provided")
|
||||
|
||||
content = await file.read()
|
||||
if len(content) > MAX_FILE_SIZE:
|
||||
raise HTTPException(status_code=413, detail="File too large")
|
||||
|
||||
return content
|
||||
|
||||
@router.post("/files", response_model=CreateFileResponse, status_code=201)
|
||||
async def create_file(
|
||||
background_tasks: BackgroundTasks,
|
||||
file: UploadFile = File(...),
|
||||
metadata: CreateFileRequest = Depends(get_create_file_metadata),
|
||||
file_service: FileService = Depends(get_file_service),
|
||||
task_service: TaskService = Depends(get_task_service)
|
||||
) -> CreateFileResponse:
|
||||
"""Create a new file entry with the uploaded file."""
|
||||
try:
|
||||
content = await validate_file_upload(file)
|
||||
logger.debug(f"File received: {file.filename}")
|
||||
logger.debug(f"Metadata: {metadata}")
|
||||
|
||||
metadata.filename = metadata.filename or file.filename
|
||||
|
||||
if not file_service.validate_file(content, metadata):
|
||||
raise HTTPException(status_code=400, detail="Invalid file content")
|
||||
|
||||
created_file = file_service.create_file(content, metadata)
|
||||
|
||||
if metadata.source == 'manabox':
|
||||
background_tasks.add_task(task_service.process_manabox_file, created_file)
|
||||
|
||||
return CreateFileResponse(
|
||||
status_code=201,
|
||||
success=True,
|
||||
files=[FileSchema.from_orm(created_file)]
|
||||
)
|
||||
|
||||
except HTTPException as http_ex:
|
||||
raise http_ex
|
||||
except Exception as e:
|
||||
logger.error(f"File upload failed: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Internal server error occurred during file upload"
|
||||
)
|
||||
finally:
|
||||
await file.close()
|
||||
|
||||
@router.get("/files/{file_id:path}", response_model=GetFileResponse)
|
||||
@router.get("/files", response_model=GetFileResponse)
|
||||
async def get_file(
|
||||
file_id: Optional[str] = None,
|
||||
query: GetFileQueryParams = Depends(),
|
||||
file_service: FileService = Depends(get_file_service)
|
||||
) -> GetFileResponse:
|
||||
"""
|
||||
Get file(s) by optional ID and/or status.
|
||||
If file_id is provided, returns that specific file.
|
||||
If status is provided, returns all files with that status.
|
||||
If neither is provided, returns all files.
|
||||
"""
|
||||
try:
|
||||
if file_id:
|
||||
file = file_service.get_file(file_id)
|
||||
files = [file]
|
||||
else:
|
||||
files = file_service.get_files(status=query.status)
|
||||
|
||||
return GetFileResponse(
|
||||
status_code=200,
|
||||
success=True,
|
||||
files=[FileSchema.from_orm(f) for f in files]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Get file(s) failed: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.delete("/files/{file_id}", response_model=DeleteFileResponse)
|
||||
async def delete_file(
|
||||
file_id: str,
|
||||
file_service: FileService = Depends(get_file_service)
|
||||
) -> DeleteFileResponse:
|
||||
"""Delete a file by ID."""
|
||||
try:
|
||||
file = file_service.delete_file(file_id)
|
||||
return DeleteFileResponse(
|
||||
status_code=200,
|
||||
success=True,
|
||||
files=[FileSchema.from_orm(file)]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Delete file failed: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.post("/boxes", response_model=CreateBoxResponse, status_code=201)
|
||||
async def create_box(
|
||||
box_data: CreateBoxRequest = Depends(get_box_data),
|
||||
box_service: BoxService = Depends(get_box_service)
|
||||
) -> CreateBoxResponse:
|
||||
"""Create a new box."""
|
||||
try:
|
||||
result, success = box_service.create_box(box_data)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Box creation failed, box already exists")
|
||||
return CreateBoxResponse(
|
||||
status_code=201,
|
||||
success=True,
|
||||
box=[BoxSchema.from_orm(result)]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Create box failed: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.put("/boxes/{box_id}", response_model=CreateBoxResponse)
|
||||
async def update_box(
|
||||
box_id: str,
|
||||
box_data: UpdateBoxRequest = Depends(get_box_update_data),
|
||||
box_service: BoxService = Depends(get_box_service)
|
||||
) -> CreateBoxResponse:
|
||||
"""Update an existing box."""
|
||||
try:
|
||||
result = box_service.update_box(box_id, box_data)
|
||||
return CreateBoxResponse(
|
||||
status_code=200,
|
||||
success=True,
|
||||
box=[BoxSchema.from_orm(result)]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Update box failed: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.delete("/boxes/{box_id}", response_model=CreateBoxResponse)
|
||||
async def delete_box(
|
||||
box_id: str,
|
||||
box_service: BoxService = Depends(get_box_service)
|
||||
) -> CreateBoxResponse:
|
||||
"""Delete a box by ID."""
|
||||
try:
|
||||
result = box_service.delete_box(box_id)
|
||||
return CreateBoxResponse(
|
||||
status_code=200,
|
||||
success=True,
|
||||
box=[BoxSchema.from_orm(result)]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Delete box failed: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.post("/boxes/{box_id}/open", response_model=CreateOpenBoxResponse, status_code=201)
|
||||
async def open_box(
|
||||
box_id: str,
|
||||
box_data: CreateOpenBoxRequest = Depends(get_open_box_data),
|
||||
box_service: BoxService = Depends(get_box_service)
|
||||
) -> CreateOpenBoxResponse:
|
||||
"""Open a box by ID."""
|
||||
try:
|
||||
result = box_service.open_box(box_id, box_data)
|
||||
return CreateOpenBoxResponse(
|
||||
status_code=201,
|
||||
success=True,
|
||||
open_box=[OpenBoxSchema.from_orm(result)]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Open box failed: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.delete("/boxes/{box_id}/open", response_model=CreateOpenBoxResponse, status_code=200)
|
||||
async def delete_open_box(
|
||||
box_id: str,
|
||||
box_service: BoxService = Depends(get_box_service)
|
||||
) -> CreateOpenBoxResponse:
|
||||
"""Delete an open box by ID."""
|
||||
try:
|
||||
result = box_service.delete_open_box(box_id)
|
||||
return CreateOpenBoxResponse(
|
||||
status_code=201,
|
||||
success=True,
|
||||
open_box=[OpenBoxSchema.from_orm(result)]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Delete open box failed: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e)
|
||||
)
|
||||
|
||||
class InventoryAddRequest(BaseModel):
|
||||
open_box_ids: List[str]
|
||||
|
||||
|
||||
@router.post("/tcgplayer/inventory/add", response_class=StreamingResponse)
|
||||
async def create_inventory_add_file(
|
||||
body: InventoryAddRequest,
|
||||
pricing_service: PricingService = Depends(get_pricing_service),
|
||||
):
|
||||
"""Create a new inventory add file for download."""
|
||||
try:
|
||||
content = pricing_service.generate_tcgplayer_inventory_update_file_with_pricing(body.open_box_ids)
|
||||
|
||||
stream = BytesIO(content)
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
return StreamingResponse(
|
||||
iter([stream.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={
|
||||
'Content-Disposition': f'attachment; filename="inventory_add_{timestamp}.csv"'
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Create inventory add file failed: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.get("/tcgplayer/inventory/update", response_class=StreamingResponse)
|
||||
async def create_inventory_update_file(
|
||||
pricing_service: PricingService = Depends(get_pricing_service),
|
||||
):
|
||||
"""Create a new inventory update file for download."""
|
||||
try:
|
||||
content = pricing_service.generate_tcgplayer_inventory_update_file_with_pricing()
|
||||
|
||||
stream = BytesIO(content)
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
return StreamingResponse(
|
||||
iter([stream.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={
|
||||
'Content-Disposition': f'attachment; filename="inventory_update_{timestamp}.csv"'
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Create inventory update file failed: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
class CookieUpdate(BaseModel):
|
||||
cookies: dict
|
||||
|
||||
# cookies
|
||||
@router.post("/cookies", response_model=dict)
|
||||
async def update_cookies(
|
||||
cookie_data: CookieUpdate
|
||||
):
|
||||
try:
|
||||
# see if cookie file exists
|
||||
if not os.path.exists('cookies') or os.path.exists('cookies/tcg_cookies.json'):
|
||||
logger.info("Cannot find cookies")
|
||||
# Create cookies directory if it doesn't exist
|
||||
os.makedirs('cookies', exist_ok=True)
|
||||
|
||||
# Save cookies with timestamp
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
cookie_path = 'cookies/tcg_cookies.json'
|
||||
|
||||
# Save new cookies
|
||||
with open(cookie_path, 'w') as f:
|
||||
json.dump(cookie_data.cookies, f, indent=2)
|
||||
|
||||
return {"message": "Cookies updated successfully"}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to update cookies: {str(e)}"
|
||||
)
|
||||
|
||||
class TCGPlayerOrderRequest(BaseModel):
|
||||
# optional
|
||||
order_ids: Optional[List[str]] = None
|
||||
|
||||
@router.post("/processOrders", response_model=ProcessOrdersResponse)
|
||||
async def process_orders(
|
||||
body: TCGPlayerOrderRequest,
|
||||
tcgplayer_api_service: TCGPlayerAPIService = Depends(get_tcgplayer_api_service),
|
||||
) -> ProcessOrdersResponse:
|
||||
"""Process TCGPlayer orders."""
|
||||
try:
|
||||
orders = tcgplayer_api_service.process_open_orders(body.order_ids)
|
||||
return ProcessOrdersResponse(
|
||||
status_code=200,
|
||||
success=True,
|
||||
orders=orders
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Process orders failed: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
11
app/schemas/base.py
Normal file
11
app/schemas/base.py
Normal file
@ -0,0 +1,11 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# Base schemas with shared attributes
|
||||
class BaseSchema(BaseModel):
|
||||
date_created: datetime
|
||||
date_modified: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True # Allows conversion from SQLAlchemy models
|
66
app/schemas/box.py
Normal file
66
app/schemas/box.py
Normal file
@ -0,0 +1,66 @@
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from app.schemas.base import BaseSchema
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
#BOX
|
||||
class BoxSchema(BaseSchema):
|
||||
product_id: str = Field(..., title="Product ID")
|
||||
type: str = Field(..., title="Box Type (collector, play, draft)")
|
||||
set_code: str = Field(..., title="Set Code")
|
||||
sku: Optional[str] = Field(None, title="SKU")
|
||||
num_cards_expected: Optional[int] = Field(None, title="Number of cards expected")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
# CREATE
|
||||
# REQUEST
|
||||
class CreateBoxRequest(BaseModel):
|
||||
type: str = Field(..., title="Box Type (collector, play, draft)")
|
||||
set_code: str = Field(..., title="Set Code")
|
||||
sku: Optional[str] = Field(None, title="SKU")
|
||||
num_cards_expected: Optional[int] = Field(None, title="Number of cards expected")
|
||||
|
||||
# RESPONSE
|
||||
class CreateBoxResponse(BaseModel):
|
||||
status_code: int = Field(..., title="status_code")
|
||||
success: bool = Field(..., title="success")
|
||||
box: list[BoxSchema] = Field(..., title="box")
|
||||
|
||||
# UPDATE
|
||||
# REQUEST
|
||||
class UpdateBoxRequest(BaseModel):
|
||||
type: Optional[str] = Field(None, title="Box Type (collector, play, draft)")
|
||||
set_code: Optional[str] = Field(None, title="Set Code")
|
||||
sku: Optional[str] = Field(None, title="SKU")
|
||||
num_cards_expected: Optional[int] = Field(None, title="Number of cards expected")
|
||||
|
||||
# GET
|
||||
# RESPONSE
|
||||
class GetBoxResponse(BaseModel):
|
||||
status_code: int = Field(..., title="status_code")
|
||||
success: bool = Field(..., title="success")
|
||||
boxes: list[BoxSchema] = Field(..., title="boxes")
|
||||
|
||||
|
||||
# OPEN BOX
|
||||
class OpenBoxSchema(BaseModel):
|
||||
id: str = Field(..., title="id")
|
||||
num_cards_actual: Optional[int] = Field(None, title="Number of cards actual")
|
||||
date_opened: Optional[datetime] = Field(None, title="Date Opened")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
# CREATE
|
||||
# REQUEST
|
||||
class CreateOpenBoxRequest(BaseModel):
|
||||
product_id: str = Field(..., title="Product ID")
|
||||
file_ids: list[str] = Field(None, title="File IDs")
|
||||
num_cards_actual: Optional[int] = Field(None, title="Number of cards actual")
|
||||
date_opened: Optional[str] = Field(None, title="Date Opened")
|
||||
|
||||
# RESPONSE
|
||||
class CreateOpenBoxResponse(BaseModel):
|
||||
status_code: int = Field(..., title="status_code")
|
||||
success: bool = Field(..., title="success")
|
||||
open_box: list[OpenBoxSchema] = Field(..., title="open_box")
|
51
app/schemas/file.py
Normal file
51
app/schemas/file.py
Normal file
@ -0,0 +1,51 @@
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# FILE
|
||||
class FileSchema(BaseModel):
|
||||
id: str = Field(..., title="id")
|
||||
filename: str = Field(..., title="filename")
|
||||
type: str = Field(..., title="type")
|
||||
filesize_kb: float = Field(..., title="filesize_kb")
|
||||
source: str = Field(..., title="source")
|
||||
status: str = Field(..., title="status")
|
||||
service: Optional[str] = Field(None, title="service")
|
||||
date_created: datetime = Field(..., title="date_created")
|
||||
date_modified: datetime = Field(..., title="date_modified")
|
||||
|
||||
# This enables ORM mode
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
# CREATE
|
||||
# REQUEST
|
||||
class CreateFileRequest(BaseModel):
|
||||
source: str = Field(..., title="source")
|
||||
type: str = Field(..., title="type")
|
||||
# optional
|
||||
service: Optional[str] = Field(None, title="Service")
|
||||
filename: Optional[str] = Field(None, title="Filename")
|
||||
|
||||
# RESPONSE
|
||||
class CreateFileResponse(BaseModel):
|
||||
status_code: int = Field(..., title="status_code")
|
||||
success: bool = Field(..., title="success")
|
||||
files: list[FileSchema] = Field(..., title="files")
|
||||
|
||||
# GET
|
||||
# RESPONSE
|
||||
class GetFileResponse(BaseModel):
|
||||
status_code: int = Field(..., title="status_code")
|
||||
success: bool = Field(..., title="success")
|
||||
files: list[FileSchema] = Field(..., title="files")
|
||||
# QUERY PARAMS
|
||||
class GetFileQueryParams(BaseModel):
|
||||
status: Optional[str] = Field(None, title="status")
|
||||
|
||||
# DELETE
|
||||
# RESPONSE
|
||||
class DeleteFileResponse(BaseModel):
|
||||
status_code: int = Field(..., title="status_code")
|
||||
success: bool = Field(..., title="success")
|
||||
files: list[FileSchema] = Field(..., title="files")
|
5
app/schemas/inventory.py
Normal file
5
app/schemas/inventory.py
Normal file
@ -0,0 +1,5 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class UpdateInventoryResponse(BaseModel):
|
||||
success: bool = Field(..., title="Success")
|
9
app/schemas/orders.py
Normal file
9
app/schemas/orders.py
Normal file
@ -0,0 +1,9 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
class OrderSchema(BaseModel):
|
||||
order_id: str
|
||||
|
||||
class ProcessOrdersResponse(BaseModel):
|
||||
status_code: int
|
||||
success: bool
|
||||
orders: list[str]
|
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
203
app/services/box.py
Normal file
203
app/services/box.py
Normal file
@ -0,0 +1,203 @@
|
||||
from datetime import datetime
|
||||
from typing import Dict, List
|
||||
from uuid import uuid4
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
import logging
|
||||
|
||||
from app.db.models import (
|
||||
Box,
|
||||
File,
|
||||
StagedFileProduct,
|
||||
Product,
|
||||
OpenBoxCard,
|
||||
OpenBox,
|
||||
TCGPlayerGroups,
|
||||
Inventory
|
||||
)
|
||||
from app.db.utils import db_transaction
|
||||
from app.schemas.box import CreateBoxRequest, UpdateBoxRequest, CreateOpenBoxRequest
|
||||
from app.services.inventory import InventoryService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_BOX_TYPES = {"collector", "play", "draft", "set", "commander"}
|
||||
|
||||
class BoxService:
|
||||
def __init__(self, db: Session, inventory_service: InventoryService):
|
||||
self.db = db
|
||||
self.inventory_service = inventory_service
|
||||
|
||||
def get_staged_product_data(self, file_ids: List[str]) -> List[StagedFileProduct]:
|
||||
"""Retrieve staged product data for given file IDs."""
|
||||
return self.db.query(StagedFileProduct).filter(
|
||||
StagedFileProduct.file_id.in_(file_ids)
|
||||
).all()
|
||||
|
||||
def aggregate_staged_product_data(self, staged_product_data: List[StagedFileProduct]) -> Dict[Product, int]:
|
||||
"""Aggregate staged product data by product and quantity."""
|
||||
product_data = {}
|
||||
for row in staged_product_data:
|
||||
product = self.db.query(Product).filter(Product.id == row.product_id).first()
|
||||
if product:
|
||||
product_data[product] = product_data.get(product, 0) + row.quantity
|
||||
return product_data
|
||||
|
||||
def add_products_to_open_box(self, open_box: OpenBox, product_data: Dict[Product, int]) -> None:
|
||||
"""Add products to an open box."""
|
||||
for product, quantity in product_data.items(): # TODO BATCH THIS
|
||||
open_box_card = OpenBoxCard(
|
||||
id=str(uuid4()),
|
||||
open_box_id=open_box.id,
|
||||
card_id=product.id,
|
||||
quantity=quantity
|
||||
)
|
||||
self.db.add(open_box_card)
|
||||
|
||||
def validate_box_type(self, box_type: str) -> bool:
|
||||
"""Validate if the box type is supported."""
|
||||
return box_type in VALID_BOX_TYPES
|
||||
|
||||
def validate_set_code(self, set_code: str) -> bool:
|
||||
"""Validate if the set code exists in TCGPlayer groups."""
|
||||
return self.db.query(TCGPlayerGroups).filter(
|
||||
TCGPlayerGroups.abbreviation == set_code
|
||||
).first() is not None
|
||||
|
||||
def create_box(self, create_box_data: CreateBoxRequest) -> Box:
|
||||
"""Create a new box."""
|
||||
if not self.validate_box_type(create_box_data.type):
|
||||
raise ValueError("Invalid box type")
|
||||
if not self.validate_set_code(create_box_data.set_code):
|
||||
raise ValueError("Invalid set code")
|
||||
|
||||
existing_box = self.db.query(Box).filter(
|
||||
Box.type == create_box_data.type,
|
||||
Box.set_code == create_box_data.set_code,
|
||||
or_(Box.sku == create_box_data.sku, Box.sku.is_(None))
|
||||
).first()
|
||||
|
||||
if existing_box:
|
||||
return existing_box, False
|
||||
else:
|
||||
with db_transaction(self.db):
|
||||
product = Product(
|
||||
id=str(uuid4()),
|
||||
type='box',
|
||||
product_line='mtg'
|
||||
)
|
||||
self.db.add(product)
|
||||
self.db.flush()
|
||||
box = Box(
|
||||
product_id=product.id,
|
||||
type=create_box_data.type,
|
||||
set_code=create_box_data.set_code,
|
||||
sku=create_box_data.sku,
|
||||
num_cards_expected=create_box_data.num_cards_expected
|
||||
)
|
||||
self.db.add(box)
|
||||
|
||||
return box, True
|
||||
|
||||
def update_box(self, box_id: str, update_box_data: UpdateBoxRequest) -> Box:
|
||||
"""Update an existing box."""
|
||||
box = self.db.query(Box).filter(Box.product_id == box_id).first()
|
||||
if not box:
|
||||
raise ValueError("Box not found")
|
||||
|
||||
update_data = update_box_data.dict(exclude_unset=True)
|
||||
|
||||
# Validate box type if it's being updated
|
||||
if "type" in update_data and update_data["type"] is not None:
|
||||
if not self.validate_box_type(update_data["type"]):
|
||||
raise ValueError(f"Invalid box type: {update_data['type']}")
|
||||
|
||||
# Validate set code if it's being updated
|
||||
if "set_code" in update_data and update_data["set_code"] is not None:
|
||||
if not self.validate_set_code(update_data["set_code"]):
|
||||
raise ValueError(f"Invalid set code: {update_data['set_code']}")
|
||||
|
||||
with db_transaction(self.db):
|
||||
for field, value in update_data.items():
|
||||
if value is not None: # Only update non-None values
|
||||
setattr(box, field, value)
|
||||
|
||||
return box
|
||||
|
||||
def delete_box(self, box_id: str) -> Box:
|
||||
"""Delete a box."""
|
||||
box = self.db.query(Box).filter(Box.product_id == box_id).first()
|
||||
product = self.db.query(Product).filter(Product.id == box_id).first()
|
||||
if not box:
|
||||
raise ValueError("Box not found")
|
||||
|
||||
with db_transaction(self.db):
|
||||
self.db.delete(box)
|
||||
self.db.delete(product)
|
||||
return box
|
||||
|
||||
def open_box(self, box_id: str, box_data: CreateOpenBoxRequest) -> OpenBox:
|
||||
"""Open a box and process its contents."""
|
||||
box = self.db.query(Box).filter(Box.product_id == box_id).first()
|
||||
if not box:
|
||||
raise ValueError("Box not found")
|
||||
|
||||
with db_transaction(self.db):
|
||||
open_box = OpenBox(
|
||||
id=str(uuid4()),
|
||||
product_id=box_id,
|
||||
num_cards_actual=box_data.num_cards_actual,
|
||||
date_opened=datetime.strptime(box_data.date_opened, "%Y-%m-%d") if box_data.date_opened else datetime.now()
|
||||
)
|
||||
self.db.add(open_box)
|
||||
|
||||
staged_product_data = self.get_staged_product_data(box_data.file_ids)
|
||||
product_data = self.aggregate_staged_product_data(staged_product_data)
|
||||
self.inventory_service.process_staged_products(product_data)
|
||||
self.add_products_to_open_box(open_box, product_data)
|
||||
|
||||
# Update file box IDs
|
||||
self.db.query(File).filter(File.id.in_(box_data.file_ids)).update(
|
||||
{"box_id": open_box.id}, synchronize_session=False
|
||||
)
|
||||
|
||||
return open_box
|
||||
|
||||
def delete_open_box(self, box_id: str) -> OpenBox:
|
||||
# Fetch open box and related cards in one query
|
||||
open_box = (
|
||||
self.db.query(OpenBox)
|
||||
.filter(OpenBox.id == box_id)
|
||||
.first()
|
||||
)
|
||||
if not open_box:
|
||||
raise ValueError("Open box not found")
|
||||
|
||||
# Get all open box cards and related inventory items in one query
|
||||
open_box_cards = (
|
||||
self.db.query(OpenBoxCard, Inventory)
|
||||
.join(
|
||||
Inventory,
|
||||
OpenBoxCard.card_id == Inventory.product_id
|
||||
)
|
||||
.filter(OpenBoxCard.open_box_id == open_box.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Process inventory adjustments
|
||||
for open_box_card, inventory_item in open_box_cards:
|
||||
if open_box_card.quantity > inventory_item.quantity:
|
||||
raise ValueError("Open box quantity exceeds inventory quantity")
|
||||
|
||||
inventory_item.quantity -= open_box_card.quantity
|
||||
if inventory_item.quantity == 0:
|
||||
self.db.delete(inventory_item)
|
||||
|
||||
# Delete the open box card
|
||||
self.db.delete(open_box_card)
|
||||
|
||||
# Execute all database operations in a single transaction
|
||||
with db_transaction(self.db):
|
||||
self.db.delete(open_box)
|
||||
|
||||
return open_box
|
156
app/services/file.py
Normal file
156
app/services/file.py
Normal file
@ -0,0 +1,156 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List, Dict, Any
|
||||
from uuid import uuid4
|
||||
import csv
|
||||
import logging
|
||||
import os
|
||||
from io import StringIO
|
||||
|
||||
from app.db.utils import db_transaction
|
||||
from app.db.models import File, StagedFileProduct
|
||||
from app.schemas.file import CreateFileRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class FileConfig:
|
||||
"""Configuration constants for file processing"""
|
||||
TEMP_DIR = os.path.join(os.getcwd(), 'app/' + 'temp')
|
||||
|
||||
MANABOX_HEADERS = [
|
||||
'Name', 'Set code', 'Set name', 'Collector number', 'Foil',
|
||||
'Rarity', 'Quantity', 'ManaBox ID', 'Scryfall ID', 'Purchase price',
|
||||
'Misprint', 'Altered', 'Condition', 'Language', 'Purchase price currency'
|
||||
]
|
||||
|
||||
SOURCES = {
|
||||
"manabox": {
|
||||
"required_headers": MANABOX_HEADERS,
|
||||
"allowed_extensions": ['.csv'],
|
||||
"allowed_types": ['scan_export_common', 'scan_export_rare']
|
||||
}
|
||||
}
|
||||
|
||||
class FileValidationError(Exception):
|
||||
"""Custom exception for file validation errors"""
|
||||
pass
|
||||
|
||||
class FileService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_config(self, source: str) -> Dict[str, Any]:
|
||||
"""Get configuration for a specific source"""
|
||||
config = FileConfig.SOURCES.get(source)
|
||||
if not config:
|
||||
raise FileValidationError(f"Unsupported source: {source}")
|
||||
return config
|
||||
|
||||
def validate_file_extension(self, filename: str, config: Dict[str, Any]) -> bool:
|
||||
"""Validate file extension against allowed extensions"""
|
||||
return any(filename.endswith(ext) for ext in config["allowed_extensions"])
|
||||
|
||||
def validate_file_type(self, metadata: CreateFileRequest, config: Dict[str, Any]) -> bool:
|
||||
"""Validate file type against allowed types"""
|
||||
return metadata.type in config["allowed_types"]
|
||||
|
||||
def validate_csv(self, content: bytes, required_headers: Optional[List[str]] = None) -> bool:
|
||||
"""Validate CSV content and headers"""
|
||||
try:
|
||||
csv_text = content.decode('utf-8')
|
||||
csv_file = StringIO(csv_text)
|
||||
csv_reader = csv.reader(csv_file)
|
||||
|
||||
if required_headers:
|
||||
headers = next(csv_reader, None)
|
||||
if not headers or not all(header in headers for header in required_headers):
|
||||
return False
|
||||
return True
|
||||
|
||||
except (UnicodeDecodeError, csv.Error) as e:
|
||||
logger.error(f"CSV validation error: {str(e)}")
|
||||
return False
|
||||
|
||||
def validate_file_content(self, content: bytes, metadata: CreateFileRequest, config: Dict[str, Any]) -> bool:
|
||||
"""Validate file content based on file type"""
|
||||
extension = os.path.splitext(metadata.filename)[1].lower()
|
||||
if extension == '.csv':
|
||||
return self.validate_csv(content, config.get("required_headers"))
|
||||
return False
|
||||
|
||||
def validate_file(self, content: bytes, metadata: CreateFileRequest) -> bool:
|
||||
"""Validate file against all criteria"""
|
||||
config = self.get_config(metadata.source)
|
||||
|
||||
if not self.validate_file_extension(metadata.filename, config):
|
||||
raise FileValidationError("Invalid file extension")
|
||||
|
||||
if not self.validate_file_type(metadata, config):
|
||||
raise FileValidationError("Invalid file type")
|
||||
|
||||
if not self.validate_file_content(content, metadata, config):
|
||||
raise FileValidationError("Invalid file content or headers")
|
||||
|
||||
return True
|
||||
|
||||
def create_file(self, content: bytes, metadata: CreateFileRequest) -> File:
|
||||
"""Create a new file record and save the file"""
|
||||
with db_transaction(self.db):
|
||||
file = File(
|
||||
id=str(uuid4()),
|
||||
filename=metadata.filename,
|
||||
filepath=os.path.join(FileConfig.TEMP_DIR, metadata.filename),
|
||||
type=metadata.type,
|
||||
source=metadata.source,
|
||||
filesize_kb=round(len(content) / 1024, 2),
|
||||
status='pending',
|
||||
service=metadata.service
|
||||
)
|
||||
self.db.add(file)
|
||||
|
||||
os.makedirs(FileConfig.TEMP_DIR, exist_ok=True)
|
||||
with open(file.filepath, 'wb') as f:
|
||||
f.write(content)
|
||||
|
||||
return file
|
||||
|
||||
def get_file(self, file_id: str) -> File:
|
||||
"""Get a file by ID"""
|
||||
file = self.db.query(File).filter(File.id == file_id).first()
|
||||
if not file:
|
||||
raise FileValidationError(f"File with id {file_id} not found")
|
||||
return file
|
||||
|
||||
def get_files(self, status: Optional[str] = None) -> List[File]:
|
||||
"""Get all files, optionally filtered by status"""
|
||||
query = self.db.query(File)
|
||||
if status:
|
||||
query = query.filter(File.status == status)
|
||||
return query.all()
|
||||
|
||||
def get_staged_products(self, file_id: str) -> List[StagedFileProduct]:
|
||||
"""Get staged products for a file"""
|
||||
return self.db.query(StagedFileProduct).filter(
|
||||
StagedFileProduct.file_id == file_id
|
||||
).all()
|
||||
|
||||
def delete_file(self, file_id: str) -> File:
|
||||
"""Mark a file as deleted and remove associated staged products"""
|
||||
file = self.get_file(file_id)
|
||||
staged_products = self.get_staged_products(file_id)
|
||||
|
||||
with db_transaction(self.db):
|
||||
file.status = 'deleted'
|
||||
for staged_product in staged_products:
|
||||
self.db.delete(staged_product)
|
||||
|
||||
return file
|
||||
|
||||
def get_file_content(self, file_id: str) -> bytes:
|
||||
"""Get the content of a file"""
|
||||
file = self.get_file(file_id)
|
||||
try:
|
||||
with open(file.filepath, 'rb') as f:
|
||||
return f.read()
|
||||
except IOError as e:
|
||||
logger.error(f"Error reading file {file_id}: {str(e)}")
|
||||
raise FileValidationError(f"Could not read file content for {file_id}")
|
91
app/services/inventory.py
Normal file
91
app/services/inventory.py
Normal file
@ -0,0 +1,91 @@
|
||||
from typing import Dict
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.db.models import Product, Inventory
|
||||
from app.schemas.inventory import UpdateInventoryResponse
|
||||
from app.db.utils import db_transaction
|
||||
|
||||
|
||||
class InventoryService:
|
||||
"""Service class for managing product inventory operations."""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
"""
|
||||
Initialize the InventoryService.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy database session
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
def add_inventory(self, product: Product, quantity: int) -> Inventory:
|
||||
"""
|
||||
Add or update inventory for a product.
|
||||
|
||||
Args:
|
||||
product: Product model instance
|
||||
quantity: Quantity to add to inventory
|
||||
|
||||
Returns:
|
||||
Updated Inventory model instance
|
||||
"""
|
||||
inventory = self.db.query(Inventory).filter(
|
||||
Inventory.product_id == product.id
|
||||
).first()
|
||||
|
||||
if inventory is None:
|
||||
inventory = Inventory(
|
||||
product_id=product.id,
|
||||
quantity=quantity,
|
||||
warehouse_id="0f0d01b1-97ba-4ab2-9082-22062bca9b06" # TODO FIX
|
||||
)
|
||||
self.db.add(inventory)
|
||||
else:
|
||||
inventory.quantity += quantity
|
||||
|
||||
return inventory
|
||||
|
||||
def process_staged_products(
|
||||
self,
|
||||
product_data: Dict[Product, int]
|
||||
) -> UpdateInventoryResponse:
|
||||
"""
|
||||
Process multiple products and update their inventory.
|
||||
|
||||
Args:
|
||||
product_data: Dictionary mapping Products to their quantities
|
||||
|
||||
Returns:
|
||||
Response indicating success status
|
||||
"""
|
||||
try:
|
||||
with db_transaction(self.db):
|
||||
for product, quantity in product_data.items(): # TODO BATCH THIS
|
||||
self.add_inventory(product, quantity)
|
||||
return UpdateInventoryResponse(success=True)
|
||||
except SQLAlchemyError:
|
||||
return UpdateInventoryResponse(success=False)
|
||||
|
||||
def add_sealed_box_to_inventory(
|
||||
self,
|
||||
product: Product,
|
||||
quantity: int
|
||||
) -> UpdateInventoryResponse:
|
||||
"""
|
||||
Add sealed box inventory for a single product.
|
||||
|
||||
Args:
|
||||
product: Product model instance
|
||||
quantity: Quantity to add to inventory
|
||||
|
||||
Returns:
|
||||
Response indicating success status
|
||||
"""
|
||||
try:
|
||||
with db_transaction(self.db):
|
||||
self.add_inventory(product, quantity)
|
||||
return UpdateInventoryResponse(success=True)
|
||||
except SQLAlchemyError:
|
||||
return UpdateInventoryResponse(success=False)
|
417
app/services/pricing.py
Normal file
417
app/services/pricing.py
Normal file
@ -0,0 +1,417 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.models import File, CardTCGPlayer, Price, TCGPlayerInventory
|
||||
from app.services.util._dataframe import TCGPlayerPricingRow, DataframeUtil
|
||||
from app.services.file import FileService
|
||||
from app.services.tcgplayer import TCGPlayerService
|
||||
from uuid import uuid4
|
||||
from app.db.utils import db_transaction
|
||||
from typing import List, Dict
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ACTIVE_PRICING_ALGORITHIM = 'tcgplayer_recommended_algo' # 'default_pricing_algo' or 'tcgplayer_recommended_algo'
|
||||
FREE_SHIPPING = True
|
||||
|
||||
|
||||
class PricingService:
|
||||
def __init__(self, db: Session, file_service: FileService, tcgplayer_service: TCGPlayerService):
|
||||
self.db = db
|
||||
self.file_service = file_service
|
||||
self.tcgplayer_service = tcgplayer_service
|
||||
self.df_util = DataframeUtil()
|
||||
|
||||
# function for taking a tcgplayer pricing export with all set ids and loading it into the price table
|
||||
# can be run as needed or scheduled
|
||||
def get_pricing_export_content(self, file: File = None) -> bytes:
|
||||
if ACTIVE_PRICING_ALGORITHIM == 'default_pricing_algo' and FREE_SHIPPING == True:
|
||||
print("asdf")
|
||||
if file:
|
||||
file_content = self.file_service.get_file_content(file.id)
|
||||
else:
|
||||
file = self.tcgplayer_service.get_pricing_export_for_all_products()
|
||||
file_content = self.file_service.get_file_content(file.id)
|
||||
return file_content
|
||||
|
||||
def load_pricing_csv_content_to_db(self, file_content: bytes):
|
||||
try:
|
||||
if not file_content:
|
||||
raise ValueError("No file content provided")
|
||||
|
||||
price_types = {
|
||||
"tcg_market_price": "tcg_market_price",
|
||||
"tcg_direct_low": "tcg_direct_low",
|
||||
"tcg_low_price_with_shipping": "tcg_low_price_with_shipping",
|
||||
"tcg_low_price": "tcg_low_price",
|
||||
"tcg_marketplace_price": "listed_price"
|
||||
}
|
||||
|
||||
required_columns = ["tcgplayer_id"] + list(price_types.keys())
|
||||
df = self.df_util.csv_bytes_to_df(file_content)
|
||||
|
||||
# Validate columns
|
||||
missing_columns = set(required_columns) - set(df.columns)
|
||||
if missing_columns:
|
||||
raise ValueError(f"Missing required columns: {missing_columns}")
|
||||
|
||||
# Process in true batches
|
||||
for i in range(0, len(df), 1000):
|
||||
batch = df.iloc[i:i+1000]
|
||||
pricing_rows = [TCGPlayerPricingRow(row) for _, row in batch.iterrows()]
|
||||
|
||||
# Query cards for this batch only
|
||||
tcgplayer_ids = [row.tcgplayer_id for row in pricing_rows]
|
||||
batch_cards = self.db.query(CardTCGPlayer).filter(
|
||||
CardTCGPlayer.tcgplayer_id.in_(tcgplayer_ids)
|
||||
).all()
|
||||
|
||||
existing_cards = {card.tcgplayer_id: card for card in batch_cards}
|
||||
|
||||
new_prices = []
|
||||
for row in pricing_rows:
|
||||
if row.tcgplayer_id not in existing_cards:
|
||||
continue
|
||||
|
||||
card = existing_cards[row.tcgplayer_id]
|
||||
row_prices = [
|
||||
Price(
|
||||
id=str(uuid4()),
|
||||
product_id=card.product_id,
|
||||
marketplace_id=None,
|
||||
type=price_type, # Added missing price_type
|
||||
price=getattr(row, col_name)
|
||||
)
|
||||
for col_name, price_type in price_types.items()
|
||||
if getattr(row, col_name, None) is not None and getattr(row, col_name) > 0
|
||||
]
|
||||
new_prices.extend(row_prices)
|
||||
|
||||
# Save each batch separately
|
||||
if new_prices:
|
||||
with db_transaction(self.db):
|
||||
self.db.bulk_save_objects(new_prices)
|
||||
|
||||
except Exception as e:
|
||||
raise e # Consider adding logging here
|
||||
|
||||
|
||||
def cron_load_prices(self, file: File = None):
|
||||
file_content = self.get_pricing_export_content(file)
|
||||
self.tcgplayer_service.load_tcgplayer_cards(file_content)
|
||||
self.load_pricing_csv_content_to_db(file_content)
|
||||
|
||||
def get_all_prices_for_products(self, product_ids: List[str]) -> Dict[str, Dict[str, float]]:
|
||||
all_prices = self.db.query(Price).filter(
|
||||
Price.product_id.in_(product_ids)
|
||||
).all()
|
||||
|
||||
price_lookup = {}
|
||||
for price in all_prices:
|
||||
if price.product_id not in price_lookup:
|
||||
price_lookup[price.product_id] = {}
|
||||
price_lookup[price.product_id][price.type] = price.price
|
||||
return price_lookup
|
||||
|
||||
def apply_price_to_df_columns(self, row: pd.Series, price_lookup: Dict[str, Dict[str, float]]) -> pd.Series:
|
||||
product_prices = price_lookup.get(row['product_id'], {})
|
||||
for price_type, price in product_prices.items():
|
||||
row[price_type] = price
|
||||
return row
|
||||
|
||||
def smooth_markup(self, price, markup_bands):
|
||||
"""
|
||||
Applies a smoothed markup based on the given price and markup bands.
|
||||
Uses numpy for smooth transitions.
|
||||
"""
|
||||
# Convert markup bands to lists for easy lookup
|
||||
markups = np.array(list(markup_bands.keys()))
|
||||
min_prices = np.array([x[0] for x in markup_bands.values()])
|
||||
max_prices = np.array([x[1] for x in markup_bands.values()])
|
||||
|
||||
# Find the index of the price's range
|
||||
idx = np.where((min_prices <= price) & (max_prices >= price))[0]
|
||||
|
||||
if len(idx) > 0:
|
||||
# If price is within a defined range, return the markup
|
||||
markup = markups[idx[0]]
|
||||
else:
|
||||
# If price is not directly within any range, check smooth transitions
|
||||
# Find the closest two bands for interpolation
|
||||
idx_lower = np.argmax(max_prices <= price) # Closest range below the price
|
||||
idx_upper = np.argmax(min_prices > price) # Closest range above the price
|
||||
|
||||
if idx_lower != idx_upper:
|
||||
# Linear interpolation between the two neighboring markups
|
||||
price_diff = (price - max_prices[idx_lower]) / (min_prices[idx_upper] - max_prices[idx_lower])
|
||||
markup = np.interp(price_diff, [0, 1], [markups[idx_lower], markups[idx_upper]])
|
||||
|
||||
# Apply the markup to the price
|
||||
return price * markup
|
||||
|
||||
def tcgplayer_recommended_algo(self, row: pd.Series) -> pd.Series:
|
||||
# Convert input values to Decimal for precise arithmetic
|
||||
tcg_low = Decimal(str(row.get('tcg_low_price'))) if not pd.isna(row.get('tcg_low_price')) else None
|
||||
tcg_low_shipping = Decimal(str(row.get('tcg_low_price_with_shipping'))) if not pd.isna(row.get('tcg_low_price_with_shipping')) else None
|
||||
tcg_market_price = Decimal(str(row.get('tcg_market_price'))) if not pd.isna(row.get('tcg_market_price')) else None
|
||||
current_price = Decimal(str(row.get('tcg_marketplace_price'))) if not pd.isna(row.get('tcg_marketplace_price')) else None
|
||||
total_quantity = str(row.get('total_quantity')) if not pd.isna(row.get('total_quantity')) else "0"
|
||||
added_quantity = str(row.get('add_to_quantity')) if not pd.isna(row.get('add_to_quantity')) else "0"
|
||||
quantity = int(total_quantity) + int(added_quantity)
|
||||
|
||||
if tcg_market_price is None:
|
||||
logger.warning(f"Missing pricing data for row: {row}")
|
||||
row['new_price'] = None
|
||||
return row
|
||||
|
||||
TWO_PLACES = Decimal('0.01')
|
||||
|
||||
# Original markup bands
|
||||
markup_bands = {
|
||||
Decimal('2.34'): (Decimal('0.01'), Decimal('0.50')),
|
||||
Decimal('1.36'): (Decimal('0.51'), Decimal('1.00')),
|
||||
Decimal('1.24'): (Decimal('1.01'), Decimal('3.00')),
|
||||
Decimal('1.15'): (Decimal('3.01'), Decimal('20.00')),
|
||||
Decimal('1.06'): (Decimal('20.01'), Decimal('35.00')),
|
||||
Decimal('1.05'): (Decimal('35.01'), Decimal('50.00')),
|
||||
Decimal('1.03'): (Decimal('50.01'), Decimal('100.00')),
|
||||
Decimal('1.02'): (Decimal('100.01'), Decimal('200.00')),
|
||||
Decimal('1.01'): (Decimal('200.01'), Decimal('1000.00'))
|
||||
}
|
||||
|
||||
# Adjust markups if quantity is high
|
||||
if quantity > 3:
|
||||
adjusted_bands = {}
|
||||
increment = Decimal('0.20')
|
||||
for markup, price_range in zip(markup_bands.keys(), markup_bands.values()):
|
||||
new_markup = Decimal(str(markup)) + increment
|
||||
adjusted_bands[new_markup] = price_range
|
||||
increment -= Decimal('0.02')
|
||||
markup_bands = adjusted_bands
|
||||
|
||||
#if FREE_SHIPPING:
|
||||
#if tcg_low_shipping and (tcg_low >= Decimal('5.00')):
|
||||
#tcg_compare_price = tcg_low_shipping
|
||||
#elif tcg_low_shipping and (tcg_low < Decimal('5.00')):
|
||||
#tcg_compare_price = max(tcg_low_shipping - Decimal('1.31'), tcg_low)
|
||||
#elif tcg_low:
|
||||
#tcg_compare_price = tcg_low
|
||||
#else:
|
||||
#logger.warning(f"No TCG low or shipping price available for row: {row}")
|
||||
#row['new_price'] = None
|
||||
#return row
|
||||
#else:
|
||||
#tcg_compare_price = tcg_low
|
||||
#if tcg_compare_price is None:
|
||||
#logger.warning(f"No TCG low price available for row: {row}")
|
||||
#row['new_price'] = None
|
||||
#return row
|
||||
|
||||
tcg_compare_price = tcg_low
|
||||
|
||||
# Apply the smoothed markup
|
||||
new_price = self.smooth_markup(tcg_compare_price, markup_bands)
|
||||
|
||||
# Enforce minimum price
|
||||
if new_price < Decimal('0.35'):
|
||||
new_price = Decimal('0.25')
|
||||
|
||||
# Avoid huge price drops
|
||||
if current_price is not None and Decimal(str(((current_price - new_price) / current_price))) > Decimal('0.5'):
|
||||
logger.warning(f"Price drop too large for row: {row}")
|
||||
new_price = current_price
|
||||
|
||||
# Round to 2 decimal places
|
||||
new_price = new_price.quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
|
||||
|
||||
# Convert back to float for dataframe
|
||||
row['new_price'] = float(new_price)
|
||||
|
||||
logger.debug(f"""
|
||||
card: {row['product_name']}
|
||||
TCGplayer Id: {row['tcgplayer_id']}
|
||||
Algorithm: {ACTIVE_PRICING_ALGORITHIM}
|
||||
TCG Low: {tcg_low}
|
||||
TCG Low Shipping: {tcg_low_shipping}
|
||||
TCG Market Price: {tcg_market_price}
|
||||
Current Price: {current_price}
|
||||
Total Quantity: {total_quantity}
|
||||
Added Quantity: {added_quantity}
|
||||
Quantity: {quantity}
|
||||
TCG Compare Price: {tcg_compare_price}
|
||||
New Price: {new_price}
|
||||
""")
|
||||
|
||||
return row
|
||||
|
||||
|
||||
def default_pricing_algo(self, row: pd.Series) -> pd.Series:
|
||||
"""Default pricing algorithm with complex pricing rules"""
|
||||
|
||||
# Convert input values to Decimal for precise arithmetic
|
||||
tcg_low = Decimal(str(row.get('tcg_low_price'))) if not pd.isna(row.get('tcg_low_price')) else None
|
||||
tcg_low_shipping = Decimal(str(row.get('tcg_low_price_with_shipping'))) if not pd.isna(row.get('tcg_low_price_with_shipping')) else None
|
||||
tcg_market_price = Decimal(str(row.get('tcg_market_price'))) if not pd.isna(row.get('tcg_market_price')) else None
|
||||
total_quantity = str(row.get('total_quantity')) if not pd.isna(row.get('total_quantity')) else "0"
|
||||
added_quantity = str(row.get('add_to_quantity')) if not pd.isna(row.get('add_to_quantity')) else "0"
|
||||
quantity = int(total_quantity) + int(added_quantity)
|
||||
|
||||
if tcg_market_price is None:
|
||||
logger.warning(f"Missing pricing data for row: {row}")
|
||||
row['new_price'] = None
|
||||
return row
|
||||
|
||||
# Define precision for rounding
|
||||
TWO_PLACES = Decimal('0.01')
|
||||
|
||||
# Apply pricing rules
|
||||
if tcg_market_price < Decimal('1') and tcg_market_price > Decimal('0.25'):
|
||||
new_price = tcg_market_price * Decimal('1.25')
|
||||
elif tcg_market_price < Decimal('0.25'):
|
||||
new_price = Decimal('0.25')
|
||||
elif tcg_market_price < Decimal('5'):
|
||||
new_price = tcg_market_price * Decimal('1.08')
|
||||
elif tcg_market_price < Decimal('10'):
|
||||
new_price = tcg_market_price * Decimal('1.06')
|
||||
elif tcg_market_price < Decimal('20'):
|
||||
new_price = tcg_market_price * Decimal('1.0125')
|
||||
elif tcg_market_price < Decimal('50'):
|
||||
new_price = tcg_market_price * Decimal('0.99')
|
||||
elif tcg_market_price < Decimal('100'):
|
||||
new_price = tcg_market_price * Decimal('0.98')
|
||||
else:
|
||||
new_price = tcg_market_price * Decimal('1.09')
|
||||
|
||||
if new_price < Decimal('0.25'):
|
||||
new_price = Decimal('0.25')
|
||||
|
||||
if quantity > 3:
|
||||
new_price = new_price * Decimal('1.1')
|
||||
|
||||
# Ensure exactly 2 decimal places
|
||||
new_price = new_price.quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
|
||||
|
||||
# Convert back to float or string as needed for your dataframe
|
||||
row['new_price'] = float(new_price)
|
||||
return row
|
||||
|
||||
def apply_pricing_algo(self, row: pd.Series, pricing_algo: callable = None) -> pd.Series:
|
||||
"""Modified to handle the pricing algorithm as an instance method"""
|
||||
if pricing_algo:
|
||||
logger.debug(f"Using custom pricing algorithm: {pricing_algo.__name__}")
|
||||
return pricing_algo(row)
|
||||
elif ACTIVE_PRICING_ALGORITHIM == 'default_pricing_algo':
|
||||
logger.debug(f"Using default pricing algorithm: {self.default_pricing_algo.__name__}")
|
||||
pricing_algo = self.default_pricing_algo
|
||||
elif ACTIVE_PRICING_ALGORITHIM == 'tcgplayer_recommended_algo':
|
||||
logger.debug(f"Using TCGPlayer recommended algorithm: {self.tcgplayer_recommended_algo.__name__}")
|
||||
pricing_algo = self.tcgplayer_recommended_algo
|
||||
else:
|
||||
logger.debug(f"Using default pricing algorithm: {self.default_pricing_algo.__name__}")
|
||||
pricing_algo = self.default_pricing_algo
|
||||
|
||||
return pricing_algo(row)
|
||||
|
||||
def generate_tcgplayer_inventory_update_file_with_pricing(self, open_box_ids: List[str] = None) -> bytes:
|
||||
desired_columns = [
|
||||
'TCGplayer Id', 'Product Line', 'Set Name', 'Product Name',
|
||||
'Title', 'Number', 'Rarity', 'Condition', 'TCG Market Price',
|
||||
'TCG Direct Low', 'TCG Low Price With Shipping', 'TCG Low Price',
|
||||
'Total Quantity', 'Add to Quantity', 'TCG Marketplace Price', 'Photo URL'
|
||||
]
|
||||
|
||||
if open_box_ids:
|
||||
# Get initial dataframe
|
||||
update_type = 'add'
|
||||
df = self.tcgplayer_service.open_box_cards_to_tcgplayer_inventory_df(open_box_ids)
|
||||
else:
|
||||
update_type = 'update'
|
||||
df = self.tcgplayer_service.get_inventory_df('live')
|
||||
# remove rows with total quantity of 0
|
||||
df = df[df['total_quantity'] != 0]
|
||||
tcgplayer_ids = df['tcgplayer_id'].unique().tolist()
|
||||
|
||||
# Make a single query to get all matching records
|
||||
product_id_mapping = {
|
||||
card.tcgplayer_id: card.product_id
|
||||
for card in self.db.query(CardTCGPlayer)
|
||||
.filter(CardTCGPlayer.tcgplayer_id.in_(tcgplayer_ids))
|
||||
.all()
|
||||
}
|
||||
|
||||
# Map tcgplayer_id to product_id, ensure strings, keep None if not found
|
||||
df['product_id'] = df['tcgplayer_id'].map(product_id_mapping).apply(lambda x: str(x) if pd.notnull(x) else None)
|
||||
|
||||
# Log any tcgplayer_ids that didn't map to a product_id
|
||||
null_product_ids = df[df['product_id'].isnull()]['tcgplayer_id'].tolist()
|
||||
if null_product_ids:
|
||||
logger.warning(f"The following tcgplayer_ids could not be mapped to a product_id: {null_product_ids}")
|
||||
|
||||
price_lookup = self.get_all_prices_for_products(df['product_id'].unique())
|
||||
|
||||
# Apply price columns
|
||||
df = df.apply(lambda row: self.apply_price_to_df_columns(row, price_lookup), axis=1)
|
||||
|
||||
logger.debug(f"Applying pricing algorithm: {ACTIVE_PRICING_ALGORITHIM}")
|
||||
|
||||
# set listed price
|
||||
df['listed_price'] = df['tcg_marketplace_price'].copy()
|
||||
|
||||
# Apply pricing algorithm
|
||||
df = df.apply(self.apply_pricing_algo, axis=1)
|
||||
|
||||
# if update type is update, remove rows where new_price == listed_price
|
||||
if update_type == 'update':
|
||||
df = df[df['new_price'] != df['listed_price']]
|
||||
|
||||
|
||||
# Set marketplace price
|
||||
df['TCG Marketplace Price'] = df['new_price']
|
||||
|
||||
df['Title'] = ''
|
||||
|
||||
column_mapping = {
|
||||
'tcgplayer_id': 'TCGplayer Id',
|
||||
'product_line': 'Product Line',
|
||||
'set_name': 'Set Name',
|
||||
'product_name': 'Product Name',
|
||||
'title': 'Title',
|
||||
'number': 'Number',
|
||||
'rarity': 'Rarity',
|
||||
'condition': 'Condition',
|
||||
'tcg_market_price': 'TCG Market Price',
|
||||
'tcg_direct_low': 'TCG Direct Low',
|
||||
'tcg_low_price_with_shipping': 'TCG Low Price With Shipping',
|
||||
'tcg_low_price': 'TCG Low Price',
|
||||
'total_quantity': 'Total Quantity',
|
||||
'add_to_quantity': 'Add to Quantity',
|
||||
'photo_url': 'Photo URL'
|
||||
}
|
||||
df = df.rename(columns=column_mapping)
|
||||
|
||||
# Now do your column selection
|
||||
df = df[desired_columns]
|
||||
|
||||
if update_type == 'update':
|
||||
with db_transaction(self.db):
|
||||
self.db.query(TCGPlayerInventory).delete()
|
||||
self.db.flush()
|
||||
# copy df to modify before inserting
|
||||
df_copy = df.copy()
|
||||
df_copy['id'] = df_copy.apply(lambda x: str(uuid4()), axis=1)
|
||||
# rename columns lowercase no space
|
||||
df_copy.columns = df_copy.columns.str.lower().str.replace(' ', '_')
|
||||
for index, row in df_copy.iterrows():
|
||||
tcgplayer_inventory = TCGPlayerInventory(**row.to_dict())
|
||||
self.db.add(tcgplayer_inventory)
|
||||
|
||||
# remove any rows with no price
|
||||
#df = df[df['TCG Marketplace Price'] != 0]
|
||||
#df = df[df['TCG Marketplace Price'].notna()]
|
||||
|
||||
# Convert to CSV bytes
|
||||
csv_bytes = self.df_util.df_to_csv_bytes(df)
|
||||
|
||||
return csv_bytes
|
157
app/services/print.py
Normal file
157
app/services/print.py
Normal file
@ -0,0 +1,157 @@
|
||||
from brother_ql.conversion import convert
|
||||
from brother_ql.backends.helpers import send
|
||||
from brother_ql.raster import BrotherQLRaster
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import platform
|
||||
import pandas as pd
|
||||
from time import sleep
|
||||
import pdf2image
|
||||
import io
|
||||
|
||||
# Printer settings
|
||||
printer_model = "QL-1100"
|
||||
backend = 'pyusb'
|
||||
printer = 'usb://0x04f9:0x20a7'
|
||||
|
||||
def convert_pdf_to_image(pdf_path):
|
||||
"""Converts a PDF to PIL Image"""
|
||||
try:
|
||||
# Convert PDF to image
|
||||
images = pdf2image.convert_from_path(pdf_path)
|
||||
if images:
|
||||
return images[0] # Return first page
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error converting PDF: {str(e)}")
|
||||
return None
|
||||
|
||||
def create_address_label(input_data, font_size=30, is_pdf=False):
|
||||
"""Creates and returns the label image without printing"""
|
||||
if is_pdf:
|
||||
if isinstance(input_data, str): # If path is provided
|
||||
return convert_pdf_to_image(input_data)
|
||||
else: # If PIL Image is provided
|
||||
return input_data
|
||||
|
||||
# Regular text-based label creation
|
||||
label_width = 991
|
||||
label_height = 306
|
||||
|
||||
image = Image.new('L', (label_width, label_height), 'white')
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# Font selection based on OS
|
||||
if platform.system() == 'Windows':
|
||||
font = ImageFont.truetype("C:\\Windows\\Fonts\\arial.ttf", size=font_size)
|
||||
elif platform.system() == 'Darwin':
|
||||
font = ImageFont.truetype("/Library/Fonts/Arial.ttf", size=font_size)
|
||||
elif platform.system() == 'Linux':
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/msttcorefonts/arial.ttf", size=font_size)
|
||||
|
||||
margin = 20
|
||||
lines = input_data.split('\n')
|
||||
line_height = font_size + 5
|
||||
total_height = line_height * len(lines)
|
||||
start_y = (label_height - total_height) // 2
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
y = start_y + (i * line_height)
|
||||
draw.text((margin, y), line, font=font, fill='black')
|
||||
|
||||
return image
|
||||
|
||||
def preview_label(input_data, font_size=30, is_pdf=False):
|
||||
"""Creates and displays the label preview"""
|
||||
image = create_address_label(input_data, font_size, is_pdf)
|
||||
if image:
|
||||
image.show()
|
||||
|
||||
def print_address_label(input_data, font_size=30, is_pdf=False, label_size='29x90'):
|
||||
"""Prints the label with support for both text and PDF inputs"""
|
||||
try:
|
||||
image = create_address_label(input_data, font_size, is_pdf)
|
||||
if not image:
|
||||
raise Exception("Failed to create label image")
|
||||
|
||||
if label_size == '4x6':
|
||||
target_width = 1164
|
||||
target_height = 1660
|
||||
image = image.resize((target_width, target_height), Image.LANCZOS)
|
||||
|
||||
qlr = BrotherQLRaster(printer_model)
|
||||
qlr.exception_on_warning = True
|
||||
|
||||
print("Converting image to printer instructions...")
|
||||
instructions = convert(
|
||||
qlr=qlr,
|
||||
images=[image],
|
||||
label='29x90' if label_size == '29x90' else '102x152',
|
||||
threshold=70.0,
|
||||
dither=False,
|
||||
compress=False,
|
||||
red=False,
|
||||
dpi_600=False,
|
||||
hq=True,
|
||||
cut=False
|
||||
)
|
||||
|
||||
print("Sending to printer...")
|
||||
send(
|
||||
instructions=instructions,
|
||||
printer_identifier=printer,
|
||||
backend_identifier=backend,
|
||||
blocking=True
|
||||
)
|
||||
print("Print job sent successfully")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during printing: {str(e)}")
|
||||
|
||||
def process_pirate_ship_pdf(pdf_path, preview=False):
|
||||
"""Process and print a Pirate Ship PDF shipping label"""
|
||||
if preview:
|
||||
preview_label(pdf_path, is_pdf=True)
|
||||
else:
|
||||
print_address_label(pdf_path, is_pdf=True, label_size='4x6')
|
||||
|
||||
def process_tcg_shipping_export(file_path, require_input=False, font_size=60, preview=False):
|
||||
# Load the CSV file, all columns are strings
|
||||
df = pd.read_csv(file_path, dtype=str)
|
||||
print(df.dtypes)
|
||||
for i, row in df.iterrows():
|
||||
line1 = str(row['FirstName']) + ' ' + str(row['LastName'])
|
||||
line2 = str(row['Address1'])
|
||||
if not pd.isna(row['Address2']):
|
||||
line2 += ' ' + str(row['Address2'])
|
||||
line3 = str(row['City']) + ', ' + str(row['State']) + ' ' + str(row['PostalCode'])
|
||||
address = f"{line1}\n{line2}\n{line3}"
|
||||
if preview:
|
||||
preview_label(address, font_size=font_size)
|
||||
else:
|
||||
print_address_label(address, font_size=font_size)
|
||||
if require_input:
|
||||
input("Press Enter to continue...")
|
||||
else:
|
||||
sleep(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Choose which type to process
|
||||
label_type = input("Enter label type (1 for regular, 2 for TCG, 3 for Pirate Ship): ")
|
||||
|
||||
if label_type == "1":
|
||||
address = input("Enter the address to print: ")
|
||||
preview_label(address, font_size=60)
|
||||
user_input = input("Press 'p' to print the label or any other key to cancel: ")
|
||||
if user_input.lower() == 'p':
|
||||
print_address_label(address, font_size=60)
|
||||
|
||||
elif label_type == "2":
|
||||
shipping_export_file = input("Enter the path to the TCG Player shipping export CSV file: ")
|
||||
process_tcg_shipping_export(shipping_export_file, font_size=60, preview=False)
|
||||
|
||||
elif label_type == "3":
|
||||
pirate_ship_pdf = input("Enter the path to the Pirate Ship PDF file: ")
|
||||
process_pirate_ship_pdf(pirate_ship_pdf, preview=True)
|
||||
user_input = input("Press 'p' to print the label or any other key to cancel: ")
|
||||
if user_input.lower() == 'p':
|
||||
process_pirate_ship_pdf(pirate_ship_pdf, preview=False)
|
183
app/services/product.py
Normal file
183
app/services/product.py
Normal file
@ -0,0 +1,183 @@
|
||||
from logging import getLogger
|
||||
from uuid import uuid4
|
||||
from pandas import DataFrame
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.utils import db_transaction
|
||||
from app.db.models import CardManabox, CardTCGPlayer, StagedFileProduct, TCGPlayerGroups
|
||||
from app.services.util._dataframe import ManaboxRow, DataframeUtil
|
||||
from app.services.file import FileService
|
||||
from app.services.tcgplayer import TCGPlayerService
|
||||
from app.services.storage import StorageService
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class ProductService:
|
||||
def __init__(
|
||||
self,
|
||||
db: Session,
|
||||
file_service: FileService,
|
||||
tcgplayer_service: TCGPlayerService,
|
||||
storage_service: StorageService,
|
||||
):
|
||||
self.db = db
|
||||
self.file_service = file_service
|
||||
self.tcgplayer_service = tcgplayer_service
|
||||
self.storage_service = storage_service
|
||||
self.df_util = DataframeUtil()
|
||||
|
||||
def create_staged_file_product(
|
||||
self, file_id: str, card_manabox: CardManabox, row: ManaboxRow
|
||||
) -> StagedFileProduct:
|
||||
"""Create a staged file product entry.
|
||||
|
||||
Args:
|
||||
file_id: The ID of the file being processed
|
||||
card_manabox: The Manabox card details
|
||||
row: The row data from the Manabox file
|
||||
|
||||
Returns:
|
||||
The created staged file product
|
||||
"""
|
||||
staged_product = StagedFileProduct(
|
||||
id=str(uuid4()),
|
||||
file_id=file_id,
|
||||
product_id=card_manabox.product_id,
|
||||
quantity=row.quantity,
|
||||
)
|
||||
with db_transaction(self.db):
|
||||
self.db.add(staged_product)
|
||||
return staged_product
|
||||
|
||||
def create_card_manabox(
|
||||
self, manabox_row: ManaboxRow, card_tcgplayer: CardTCGPlayer
|
||||
) -> CardManabox:
|
||||
"""Create a Manabox card entry.
|
||||
|
||||
Args:
|
||||
manabox_row: The row data from the Manabox file
|
||||
card_tcgplayer: The TCGPlayer card details
|
||||
|
||||
Returns:
|
||||
The created Manabox card
|
||||
"""
|
||||
if not card_tcgplayer:
|
||||
group = (
|
||||
self.db.query(TCGPlayerGroups)
|
||||
.filter(TCGPlayerGroups.abbreviation == manabox_row.set_code)
|
||||
.first()
|
||||
)
|
||||
card_tcgplayer = self.tcgplayer_service.get_card_tcgplayer_from_manabox_row(
|
||||
manabox_row, group.group_id
|
||||
)
|
||||
|
||||
card_manabox = CardManabox(
|
||||
product_id=card_tcgplayer.product_id,
|
||||
name=manabox_row.name,
|
||||
set_code=manabox_row.set_code,
|
||||
set_name=manabox_row.set_name,
|
||||
collector_number=manabox_row.collector_number,
|
||||
foil=manabox_row.foil,
|
||||
rarity=manabox_row.rarity,
|
||||
manabox_id=manabox_row.manabox_id,
|
||||
scryfall_id=manabox_row.scryfall_id,
|
||||
condition=manabox_row.condition,
|
||||
language=manabox_row.language,
|
||||
)
|
||||
|
||||
with db_transaction(self.db):
|
||||
self.db.add(card_manabox)
|
||||
return card_manabox
|
||||
|
||||
def card_manabox_lookup_create_if_not_exist(
|
||||
self, manabox_row: ManaboxRow
|
||||
) -> CardManabox:
|
||||
"""Lookup a Manabox card or create it if it doesn't exist.
|
||||
|
||||
Args:
|
||||
manabox_row: The row data from the Manabox file
|
||||
|
||||
Returns:
|
||||
The existing or newly created Manabox card
|
||||
"""
|
||||
card_manabox = (
|
||||
self.db.query(CardManabox)
|
||||
.filter(
|
||||
CardManabox.name == manabox_row.name,
|
||||
CardManabox.set_code == manabox_row.set_code,
|
||||
CardManabox.set_name == manabox_row.set_name,
|
||||
CardManabox.collector_number == manabox_row.collector_number,
|
||||
CardManabox.foil == manabox_row.foil,
|
||||
CardManabox.rarity == manabox_row.rarity,
|
||||
CardManabox.manabox_id == manabox_row.manabox_id,
|
||||
CardManabox.scryfall_id == manabox_row.scryfall_id,
|
||||
CardManabox.condition == manabox_row.condition,
|
||||
CardManabox.language == manabox_row.language,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not card_manabox:
|
||||
logger.debug(f"card_manabox not found for row: {manabox_row.__dict__}")
|
||||
group = (
|
||||
self.db.query(TCGPlayerGroups)
|
||||
.filter(TCGPlayerGroups.abbreviation == manabox_row.set_code)
|
||||
.first()
|
||||
)
|
||||
if not group:
|
||||
logger.error(f"Group not found for set code: {manabox_row.set_code}")
|
||||
logger.error(f"Row data: {manabox_row.__dict__}")
|
||||
return None
|
||||
|
||||
card_tcgplayer = self.tcgplayer_service.get_card_tcgplayer_from_manabox_row(
|
||||
manabox_row, group.group_id
|
||||
)
|
||||
if not card_tcgplayer:
|
||||
logger.error(f"Card not found for row: {manabox_row.__dict__}")
|
||||
return None
|
||||
card_manabox = self.create_card_manabox(manabox_row, card_tcgplayer)
|
||||
|
||||
return card_manabox
|
||||
|
||||
def process_manabox_df(self, df: DataFrame, file_id: str) -> None:
|
||||
"""Process a Manabox dataframe.
|
||||
|
||||
Args:
|
||||
df: The Manabox dataframe to process
|
||||
file_id: The ID of the file being processed
|
||||
"""
|
||||
for _, row in df.iterrows():
|
||||
manabox_row = ManaboxRow(row)
|
||||
card_manabox = self.card_manabox_lookup_create_if_not_exist(manabox_row)
|
||||
if not card_manabox:
|
||||
continue
|
||||
self.create_staged_file_product(file_id, card_manabox, row)
|
||||
|
||||
def bg_process_manabox_file(self, file_id: str) -> None:
|
||||
"""Process a Manabox file in the background.
|
||||
|
||||
Args:
|
||||
file_id: The ID of the file to process
|
||||
|
||||
Raises:
|
||||
Exception: If there's an error during processing
|
||||
"""
|
||||
try:
|
||||
manabox_file = self.file_service.get_file(file_id)
|
||||
manabox_df = self.df_util.file_to_df(manabox_file)
|
||||
self.process_manabox_df(manabox_df, file_id)
|
||||
|
||||
with db_transaction(self.db):
|
||||
manabox_file.status = "completed"
|
||||
|
||||
except Exception as e:
|
||||
with db_transaction(self.db):
|
||||
manabox_file.status = "error"
|
||||
raise e
|
||||
|
||||
try:
|
||||
self.storage_service.store_staged_products_for_file(file_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating storage records: {str(e)}")
|
||||
raise e
|
256
app/services/storage.py
Normal file
256
app/services/storage.py
Normal file
@ -0,0 +1,256 @@
|
||||
from uuid import uuid4
|
||||
from typing import List, TypedDict
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.utils import db_transaction
|
||||
from app.db.models import (
|
||||
Warehouse,
|
||||
User,
|
||||
StagedFileProduct,
|
||||
StorageBlock,
|
||||
ProductBlock,
|
||||
File,
|
||||
CardTCGPlayer
|
||||
)
|
||||
|
||||
class ProductAttributes(TypedDict):
|
||||
"""Attributes for a product to be stored."""
|
||||
product_id: str
|
||||
card_number: str
|
||||
|
||||
class StorageService:
|
||||
"""Service for managing product storage and warehouse operations."""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
"""Initialize the storage service.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy database session
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
def get_or_create_user(self, username: str) -> User:
|
||||
"""Get an existing user or create a new one if not found.
|
||||
|
||||
Args:
|
||||
username: Username to look up or create
|
||||
|
||||
Returns:
|
||||
The existing or newly created User
|
||||
"""
|
||||
user = self.db.query(User).filter(User.username == username).first()
|
||||
if user is None:
|
||||
user = User(
|
||||
id=str(uuid4()),
|
||||
username=username
|
||||
)
|
||||
with db_transaction(self.db):
|
||||
self.db.add(user)
|
||||
return user
|
||||
|
||||
def get_or_create_warehouse(self) -> Warehouse:
|
||||
"""Get the default warehouse or create it if it doesn't exist.
|
||||
|
||||
Returns:
|
||||
The existing or newly created Warehouse
|
||||
"""
|
||||
warehouse = self.db.query(Warehouse).first()
|
||||
user = self.get_or_create_user('admin')
|
||||
if warehouse is None:
|
||||
warehouse = Warehouse(
|
||||
id=str(uuid4()),
|
||||
user_id=user.id
|
||||
)
|
||||
with db_transaction(self.db):
|
||||
self.db.add(warehouse)
|
||||
return warehouse
|
||||
|
||||
def get_staged_product(self, file_id: str) -> List[StagedFileProduct]:
|
||||
"""Get all staged products for a given file.
|
||||
|
||||
Args:
|
||||
file_id: ID of the file to get staged products for
|
||||
|
||||
Returns:
|
||||
List of staged products
|
||||
"""
|
||||
return self.db.query(StagedFileProduct).filter(
|
||||
StagedFileProduct.file_id == file_id
|
||||
).all()
|
||||
|
||||
def get_storage_block_name(self, warehouse: Warehouse, file_id: str) -> str:
|
||||
"""Generate a unique name for a new storage block.
|
||||
|
||||
Args:
|
||||
warehouse: Warehouse the block belongs to
|
||||
file_id: ID of the file being processed
|
||||
|
||||
Returns:
|
||||
Unique storage block name
|
||||
|
||||
Raises:
|
||||
ValueError: If no file is found with the given ID
|
||||
"""
|
||||
current_file = self.db.query(File).filter(File.id == file_id).first()
|
||||
if not current_file:
|
||||
raise ValueError(f"No file found with id {file_id}")
|
||||
|
||||
storage_block_type = 'rare' if 'rare' in current_file.type else 'common'
|
||||
prefix = storage_block_type[0]
|
||||
|
||||
latest_block = (
|
||||
self.db.query(StorageBlock)
|
||||
.filter(
|
||||
StorageBlock.warehouse_id == warehouse.id,
|
||||
StorageBlock.type == storage_block_type
|
||||
)
|
||||
.order_by(StorageBlock.date_created.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
start_number = 1 if not latest_block else int(latest_block.name[1:]) + 1
|
||||
|
||||
while True:
|
||||
new_name = f"{prefix}{start_number}"
|
||||
exists = (
|
||||
self.db.query(StorageBlock)
|
||||
.filter(
|
||||
StorageBlock.warehouse_id == warehouse.id,
|
||||
StorageBlock.name == new_name
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not exists:
|
||||
return new_name
|
||||
start_number += 1
|
||||
|
||||
def create_storage_block(self, warehouse: Warehouse, file_id: str) -> StorageBlock:
|
||||
"""Create a new storage block for the given warehouse and file.
|
||||
|
||||
Args:
|
||||
warehouse: Warehouse to create the block in
|
||||
file_id: ID of the file being processed
|
||||
|
||||
Returns:
|
||||
Newly created StorageBlock
|
||||
|
||||
Raises:
|
||||
ValueError: If no file is found with the given ID
|
||||
"""
|
||||
current_file = self.db.query(File).filter(File.id == file_id).first()
|
||||
if not current_file:
|
||||
raise ValueError(f"No file found with id {file_id}")
|
||||
|
||||
storage_block_type = 'rare' if 'rare' in current_file.type else 'common'
|
||||
|
||||
storage_block = StorageBlock(
|
||||
id=str(uuid4()),
|
||||
warehouse_id=warehouse.id,
|
||||
name=self.get_storage_block_name(warehouse, file_id),
|
||||
type=storage_block_type
|
||||
)
|
||||
with db_transaction(self.db):
|
||||
self.db.add(storage_block)
|
||||
return storage_block
|
||||
|
||||
def add_staged_product_to_product_block(
|
||||
self,
|
||||
staged_product: StagedFileProduct,
|
||||
storage_block: StorageBlock,
|
||||
product_attributes: ProductAttributes,
|
||||
block_index: int
|
||||
) -> ProductBlock:
|
||||
"""Create a new ProductBlock for a single unit of a staged product.
|
||||
|
||||
Args:
|
||||
staged_product: The staged product to store
|
||||
storage_block: The block to store the product in
|
||||
product_attributes: Additional product attributes
|
||||
block_index: Index within the storage block
|
||||
|
||||
Returns:
|
||||
Newly created ProductBlock
|
||||
"""
|
||||
product_block = ProductBlock(
|
||||
id=str(uuid4()),
|
||||
product_id=staged_product.product_id,
|
||||
block_id=storage_block.id,
|
||||
block_index=block_index
|
||||
)
|
||||
|
||||
with db_transaction(self.db):
|
||||
self.db.add(product_block)
|
||||
|
||||
return product_block
|
||||
|
||||
def get_staged_product_attributes_for_storage(
|
||||
self,
|
||||
staged_product: StagedFileProduct
|
||||
) -> List[ProductAttributes]:
|
||||
"""Get attributes for each unit of a staged product.
|
||||
|
||||
Args:
|
||||
staged_product: The staged product to get attributes for
|
||||
|
||||
Returns:
|
||||
List of attributes for each unit of the product
|
||||
"""
|
||||
result = (
|
||||
self.db.query(
|
||||
StagedFileProduct.product_id,
|
||||
StagedFileProduct.quantity,
|
||||
CardTCGPlayer.number
|
||||
)
|
||||
.join(CardTCGPlayer, CardTCGPlayer.product_id == StagedFileProduct.product_id)
|
||||
.filter(StagedFileProduct.id == staged_product.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not result:
|
||||
return []
|
||||
|
||||
return [
|
||||
ProductAttributes(
|
||||
product_id=result.product_id,
|
||||
card_number=result.number
|
||||
)
|
||||
for _ in range(result.quantity)
|
||||
]
|
||||
|
||||
def store_staged_products_for_file(self, file_id: str) -> StorageBlock:
|
||||
"""Store all staged products for a file in a new storage block.
|
||||
|
||||
Args:
|
||||
file_id: ID of the file containing staged products
|
||||
|
||||
Returns:
|
||||
The newly created StorageBlock containing all products
|
||||
"""
|
||||
warehouse = self.get_or_create_warehouse()
|
||||
storage_block = self.create_storage_block(warehouse, file_id)
|
||||
staged_products = self.get_staged_product(file_id)
|
||||
|
||||
# Collect all product attributes first
|
||||
all_product_attributes = []
|
||||
for staged_product in staged_products:
|
||||
product_attributes_list = self.get_staged_product_attributes_for_storage(staged_product)
|
||||
for attrs in product_attributes_list:
|
||||
all_product_attributes.append((staged_product, attrs))
|
||||
|
||||
# Sort by card number as integer to determine block indices
|
||||
sorted_attributes = sorted(
|
||||
all_product_attributes,
|
||||
key=lambda x: int(''.join(filter(str.isdigit, x[1]['card_number'])))
|
||||
)
|
||||
|
||||
# Add products with correct block indices
|
||||
for block_index, (staged_product, product_attributes) in enumerate(sorted_attributes, 1):
|
||||
self.add_staged_product_to_product_block(
|
||||
staged_product=staged_product,
|
||||
storage_block=storage_block,
|
||||
product_attributes=product_attributes,
|
||||
block_index=block_index
|
||||
)
|
||||
|
||||
return storage_block
|
50
app/services/task.py
Normal file
50
app/services/task.py
Normal file
@ -0,0 +1,50 @@
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
import logging
|
||||
from typing import Dict, Callable
|
||||
from sqlalchemy.orm import Session
|
||||
from app.services.product import ProductService
|
||||
from app.db.models import File
|
||||
from app.services.pricing import PricingService
|
||||
from app.services.tcgplayer_api import TCGPlayerAPIService
|
||||
|
||||
|
||||
class TaskService:
|
||||
def __init__(self, db: Session, product_service: ProductService, pricing_service: PricingService, tcgplayer_api_service: TCGPlayerAPIService):
|
||||
self.scheduler = BackgroundScheduler()
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.tasks: Dict[str, Callable] = {}
|
||||
self.db = db
|
||||
self.product_service = product_service
|
||||
self.pricing_service = pricing_service
|
||||
self.tcgplayer_api_service = tcgplayer_api_service
|
||||
|
||||
async def start(self):
|
||||
self.scheduler.start()
|
||||
self.logger.info("Task scheduler started.")
|
||||
self.register_scheduled_tasks()
|
||||
# self.pricing_service.generate_tcgplayer_inventory_update_file_with_pricing(['e20cc342-23cb-4593-89cb-56a0cb3ed3f3'])
|
||||
|
||||
def register_scheduled_tasks(self):
|
||||
self.scheduler.add_job(self.hourly_pricing, 'cron', minute='36')
|
||||
self.scheduler.add_job(self.hourly_orders, 'cron', hour='*', minute='03')
|
||||
# every 5 hours on the 24th minute
|
||||
#self.scheduler.add_job(self.inventory_pricing, 'cron', hour='*', minute='44')
|
||||
self.logger.info("Scheduled tasks registered.")
|
||||
|
||||
def hourly_pricing(self):
|
||||
self.logger.info("Running hourly pricing task")
|
||||
self.pricing_service.cron_load_prices()
|
||||
self.logger.info("Finished hourly pricing task")
|
||||
|
||||
def hourly_orders(self):
|
||||
self.logger.info("Running hourly orders task")
|
||||
self.tcgplayer_api_service.process_orders_task()
|
||||
self.logger.info("Finished hourly orders task")
|
||||
|
||||
def inventory_pricing(self):
|
||||
self.tcgplayer_api_service.cron_tcgplayer_api_pricing()
|
||||
|
||||
async def process_manabox_file(self, file: File):
|
||||
self.logger.info("Processing ManaBox file")
|
||||
self.product_service.bg_process_manabox_file(file.id)
|
||||
self.logger.info("Finished processing ManaBox file")
|
569
app/services/tcgplayer.py
Normal file
569
app/services/tcgplayer.py
Normal file
@ -0,0 +1,569 @@
|
||||
from app.db.models import TCGPlayerGroups, CardTCGPlayer, Product, Card, File, Inventory, OpenBox, OpenBoxCard
|
||||
import requests
|
||||
from app.services.util._dataframe import TCGPlayerPricingRow, DataframeUtil, ManaboxRow
|
||||
from app.services.file import FileService
|
||||
from app.services.inventory import InventoryService
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.utils import db_transaction
|
||||
from uuid import uuid4 as uuid
|
||||
import browser_cookie3
|
||||
import webbrowser
|
||||
from typing import Optional, Dict ,List
|
||||
from enum import Enum
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
import urllib.parse
|
||||
import json
|
||||
from datetime import datetime
|
||||
import time
|
||||
from typing import List, Dict, Optional
|
||||
import pandas as pd
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from app.schemas.file import CreateFileRequest
|
||||
import os
|
||||
from app.services.util._docker import DockerUtil
|
||||
from sqlalchemy import func
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Browser(Enum):
|
||||
"""Supported browser types for cookie extraction"""
|
||||
BRAVE = "brave"
|
||||
CHROME = "chrome"
|
||||
FIREFOX = "firefox"
|
||||
|
||||
@dataclass
|
||||
class TCGPlayerConfig:
|
||||
"""Configuration for TCGPlayer API interactions"""
|
||||
tcgplayer_base_url: str = "https://store.tcgplayer.com"
|
||||
tcgplayer_login_path: str = "/oauth/login"
|
||||
staged_inventory_download_path: str = "/Admin/Pricing/DownloadStagedInventoryExportCSV?type=Pricing"
|
||||
live_inventory_download_path = "/Admin/Pricing/DownloadMyExportCSV?type=Pricing"
|
||||
pricing_export_path: str = "/admin/pricing/downloadexportcsv"
|
||||
max_retries: int = 1
|
||||
|
||||
class TCGPlayerService:
|
||||
def __init__(self, db: Session,
|
||||
file_service: FileService,
|
||||
config: TCGPlayerConfig=TCGPlayerConfig(),
|
||||
browser_type: Browser=Browser.BRAVE):
|
||||
self.db = db
|
||||
self.config = config
|
||||
self.browser_type = browser_type
|
||||
self.cookies = None
|
||||
self.previous_request_time = None
|
||||
self.df_util = DataframeUtil()
|
||||
self.file_service = file_service
|
||||
self.docker_util = DockerUtil()
|
||||
|
||||
def _insert_groups(self, groups):
|
||||
for group in groups:
|
||||
db_group = TCGPlayerGroups(
|
||||
id=str(uuid()),
|
||||
group_id=group['groupId'],
|
||||
name=group['name'],
|
||||
abbreviation=group['abbreviation'],
|
||||
is_supplemental=group['isSupplemental'],
|
||||
published_on=group['publishedOn'],
|
||||
modified_on=group['modifiedOn'],
|
||||
category_id=group['categoryId']
|
||||
)
|
||||
self.db.add(db_group)
|
||||
|
||||
def populate_tcgplayer_groups(self):
|
||||
group_endpoint = "https://tcgcsv.com/tcgplayer/1/groups"
|
||||
response = requests.get(group_endpoint)
|
||||
response.raise_for_status()
|
||||
groups = response.json()['results']
|
||||
# manually add broken groups
|
||||
manual_groups = [
|
||||
{
|
||||
"groupId": 2422,
|
||||
"name": "Modern Horizons 2 Timeshifts",
|
||||
"abbreviation": "H2R",
|
||||
"isSupplemental": "0",
|
||||
"publishedOn": "2018-11-08T00:00:00",
|
||||
"modifiedOn": "2018-11-08T00:00:00",
|
||||
"categoryId": 1
|
||||
},
|
||||
{
|
||||
"groupId": 52,
|
||||
"name": "Store Championships",
|
||||
"abbreviation": "SCH",
|
||||
"isSupplemental": "1",
|
||||
"publishedOn": "2007-07-14T00:00:00",
|
||||
"modifiedOn": "2007-07-14T00:00:00",
|
||||
"categoryId": 1
|
||||
}
|
||||
]
|
||||
groups.extend(manual_groups)
|
||||
# Insert groups into db
|
||||
with db_transaction(self.db):
|
||||
self._insert_groups(groups)
|
||||
|
||||
def get_cookies_from_file(self) -> Dict:
|
||||
# check if cookies file exists
|
||||
if not os.path.exists('cookies/tcg_cookies.json'):
|
||||
raise ValueError("Cookies file not found")
|
||||
with open('cookies/tcg_cookies.json', 'r') as f:
|
||||
logger.debug("Loading cookies from file")
|
||||
cookies = json.load(f)
|
||||
return cookies
|
||||
|
||||
def _get_browser_cookies(self) -> Optional[Dict]:
|
||||
"""Retrieve cookies from the specified browser"""
|
||||
try:
|
||||
cookie_getter = getattr(browser_cookie3, self.browser_type.value, None)
|
||||
if not cookie_getter:
|
||||
raise ValueError(f"Unsupported browser type: {self.browser_type.value}")
|
||||
return cookie_getter()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get browser cookies: {str(e)}")
|
||||
return None
|
||||
|
||||
def _send_request(self, url: str, method: str, data=None, except_302=False) -> requests.Response:
|
||||
"""Send a request with the specified cookies"""
|
||||
# Rate limiting logic
|
||||
if self.previous_request_time:
|
||||
time_diff = (datetime.now() - self.previous_request_time).total_seconds()
|
||||
if time_diff < 10:
|
||||
logger.info(f"Waiting 10 seconds before next request...")
|
||||
time.sleep(10 - time_diff)
|
||||
|
||||
headers = self._set_headers(method)
|
||||
|
||||
# Move cookie initialization outside and make it more explicit
|
||||
if not self.cookies:
|
||||
if self.docker_util.is_in_docker():
|
||||
logger.debug("Running in Docker - using cookies from file")
|
||||
self.cookies = self.get_cookies_from_file()
|
||||
else:
|
||||
logger.debug("Not in Docker - using browser cookies")
|
||||
self.cookies = self._get_browser_cookies()
|
||||
|
||||
if not self.cookies:
|
||||
raise ValueError("Failed to retrieve cookies")
|
||||
|
||||
try:
|
||||
#logger.info(f"debug: request url {url}, method {method}, data {data}")
|
||||
response = requests.request(method, url, headers=headers, cookies=self.cookies, data=data)
|
||||
response.raise_for_status()
|
||||
|
||||
if response.status_code == 302 and not except_302:
|
||||
logger.warning("Redirecting to login page...")
|
||||
self._refresh_authentication()
|
||||
return self._send_request(url, method, except_302=True)
|
||||
|
||||
elif response.status_code == 302 and except_302:
|
||||
raise ValueError("Redirected to login page after authentication refresh")
|
||||
|
||||
self.previous_request_time = datetime.now()
|
||||
|
||||
return response
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Request failed: {str(e)}")
|
||||
return None
|
||||
|
||||
def _set_headers(self, method: str) -> Dict:
|
||||
base_headers = {
|
||||
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
||||
'accept-language': 'en-US,en;q=0.8',
|
||||
'priority': 'u=0, i',
|
||||
'referer': 'https://store.tcgplayer.com/admin/pricing',
|
||||
'sec-ch-ua': '"Not A(Brand";v="8", "Chromium";v="132", "Brave";v="132"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"macOS"',
|
||||
'sec-fetch-dest': 'document',
|
||||
'sec-fetch-mode': 'navigate',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'sec-fetch-user': '?1',
|
||||
'sec-gpc': '1',
|
||||
'upgrade-insecure-requests': '1',
|
||||
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36'
|
||||
}
|
||||
|
||||
if method == 'POST':
|
||||
post_headers = {
|
||||
'cache-control': 'max-age=0',
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'origin': 'https://store.tcgplayer.com'
|
||||
}
|
||||
base_headers.update(post_headers)
|
||||
|
||||
return base_headers
|
||||
|
||||
def _set_pricing_export_payload(self, set_name_ids: List[str]) -> Dict:
|
||||
data = {
|
||||
"PricingType": "Pricing",
|
||||
"CategoryId": "1",
|
||||
"SetNameIds": set_name_ids,
|
||||
"ConditionIds": ["1"],
|
||||
"RarityIds": ["0"],
|
||||
"LanguageIds": ["1"],
|
||||
"PrintingIds": ["0"],
|
||||
"CompareAgainstPrice": False,
|
||||
"PriceToCompare": 3,
|
||||
"ValueToCompare": 1,
|
||||
"PriceValueToCompare": None,
|
||||
"MyInventory": False,
|
||||
"ExcludeListos": False,
|
||||
"ExportLowestListingNotMe": False
|
||||
}
|
||||
payload = "model=" + urllib.parse.quote(json.dumps(data))
|
||||
return payload
|
||||
|
||||
def _refresh_authentication(self) -> None:
|
||||
"""Open browser for user to refresh authentication"""
|
||||
login_url = f"{self.config.tcgplayer_base_url}{self.config.tcgplayer_login_path}"
|
||||
logger.info("Opening browser for authentication refresh...")
|
||||
webbrowser.open(login_url)
|
||||
input('Please login and press Enter to continue...')
|
||||
# Clear existing cookies to force refresh
|
||||
self.cookies = None
|
||||
|
||||
def get_inventory_df(self, version: str) -> pd.DataFrame:
|
||||
if version == 'staged':
|
||||
inventory_download_url = f"{self.config.tcgplayer_base_url}{self.config.staged_inventory_download_path}"
|
||||
elif version == 'live':
|
||||
inventory_download_url = f"{self.config.tcgplayer_base_url}{self.config.live_inventory_download_path}"
|
||||
else:
|
||||
raise ValueError("Invalid inventory version")
|
||||
response = self._send_request(inventory_download_url, 'GET')
|
||||
df = self.df_util.csv_bytes_to_df(response.content)
|
||||
return df
|
||||
|
||||
def _get_export_csv(self, set_name_ids: List[str]) -> bytes:
|
||||
"""
|
||||
Download export CSV and save to specified path
|
||||
Returns True if successful, False otherwise
|
||||
"""
|
||||
logger.info(f"Downloading pricing export from tcgplayer with ids {set_name_ids}")
|
||||
payload = self._set_pricing_export_payload(set_name_ids)
|
||||
export_csv_download_url = f"{self.config.tcgplayer_base_url}{self.config.pricing_export_path}"
|
||||
response = self._send_request(export_csv_download_url, method='POST', data=payload)
|
||||
return response.content
|
||||
|
||||
def create_tcgplayer_card(self, row: TCGPlayerPricingRow, group_id: int):
|
||||
# if card already exists, return none
|
||||
card_exists = self.db.query(CardTCGPlayer).filter(
|
||||
CardTCGPlayer.tcgplayer_id == row.tcgplayer_id,
|
||||
CardTCGPlayer.group_id == group_id
|
||||
).first()
|
||||
if card_exists:
|
||||
return card_exists
|
||||
# create product
|
||||
product = Product(
|
||||
id=str(uuid()),
|
||||
type = 'card',
|
||||
product_line = 'mtg'
|
||||
)
|
||||
# create card
|
||||
card = Card(
|
||||
product_id=product.id,
|
||||
)
|
||||
# create Cardtcgplayer
|
||||
tcgcard = CardTCGPlayer(
|
||||
product_id=product.id,
|
||||
group_id=group_id,
|
||||
tcgplayer_id=row.tcgplayer_id,
|
||||
product_line=row.product_line,
|
||||
set_name=row.set_name,
|
||||
product_name=row.product_name,
|
||||
title=row.title,
|
||||
number=row.number,
|
||||
rarity=row.rarity,
|
||||
condition=row.condition
|
||||
)
|
||||
with db_transaction(self.db):
|
||||
self.db.add(product)
|
||||
self.db.add(card)
|
||||
self.db.add(tcgcard)
|
||||
return tcgcard
|
||||
|
||||
def create_tcgplayer_cards_batch(self, rows: list[TCGPlayerPricingRow], set_to_group: dict) -> list[CardTCGPlayer]:
|
||||
# Get existing cards in a single query
|
||||
existing_cards = {
|
||||
(card.tcgplayer_id, card.group_id): card
|
||||
for card in self.db.query(CardTCGPlayer).filter(
|
||||
CardTCGPlayer.tcgplayer_id.in_([row.tcgplayer_id for row in rows]),
|
||||
CardTCGPlayer.group_id.in_([set_to_group[row.set_name] for row in rows])
|
||||
).all()
|
||||
}
|
||||
|
||||
# Pre-allocate lists for better memory efficiency
|
||||
new_products = []
|
||||
new_cards = []
|
||||
new_tcgcards = []
|
||||
|
||||
for row in rows:
|
||||
# Get the correct group_id for this row's set
|
||||
group_id = set_to_group[row.set_name]
|
||||
|
||||
if (row.tcgplayer_id, group_id) in existing_cards:
|
||||
continue
|
||||
|
||||
product_id = str(uuid())
|
||||
|
||||
new_products.append(Product(
|
||||
id=product_id,
|
||||
type='card',
|
||||
product_line='mtg'
|
||||
))
|
||||
|
||||
new_cards.append(Card(
|
||||
product_id=product_id,
|
||||
))
|
||||
|
||||
new_tcgcards.append(CardTCGPlayer(
|
||||
product_id=product_id,
|
||||
group_id=group_id, # Use the correct group_id for this specific row
|
||||
tcgplayer_id=row.tcgplayer_id,
|
||||
product_line=row.product_line,
|
||||
set_name=row.set_name,
|
||||
product_name=row.product_name,
|
||||
title=row.title,
|
||||
number=row.number,
|
||||
rarity=row.rarity,
|
||||
condition=row.condition
|
||||
))
|
||||
|
||||
# Batch create price objects
|
||||
# row_prices = [
|
||||
# Price(
|
||||
# id=str(uuid()),
|
||||
# product_id=product_id,
|
||||
# marketplace_id=None,
|
||||
# type=price_type,
|
||||
# price=getattr(row, col_name)
|
||||
# )
|
||||
# for col_name, price_type in price_types.items()
|
||||
# if getattr(row, col_name, None) is not None and getattr(row, col_name) > 0
|
||||
# ]
|
||||
# new_prices.extend(row_prices)
|
||||
|
||||
if new_products:
|
||||
with db_transaction(self.db):
|
||||
self.db.bulk_save_objects(new_products)
|
||||
self.db.bulk_save_objects(new_cards)
|
||||
self.db.bulk_save_objects(new_tcgcards)
|
||||
# if new_prices:
|
||||
# self.db.bulk_save_objects(new_prices)
|
||||
|
||||
return new_tcgcards
|
||||
|
||||
def load_export_csv_to_card_tcgplayer(self, export_csv: bytes, file_id: str = None, batch_size: int = 1000) -> None:
|
||||
try:
|
||||
if not export_csv:
|
||||
raise ValueError("No export CSV provided")
|
||||
|
||||
df = self.df_util.csv_bytes_to_df(export_csv)
|
||||
|
||||
logger.debug(f"Loaded {len(df)} rows from export CSV")
|
||||
|
||||
# Get all group_ids upfront in a single query
|
||||
set_to_group = dict(
|
||||
self.db.query(TCGPlayerGroups.name, TCGPlayerGroups.group_id).all()
|
||||
)
|
||||
|
||||
# Process in batches
|
||||
for i in range(0, len(df), batch_size):
|
||||
batch_df = df.iloc[i:i + batch_size]
|
||||
batch_rows = [TCGPlayerPricingRow(row) for _, row in batch_df.iterrows()]
|
||||
|
||||
# Filter rows with valid group_ids
|
||||
valid_rows = [
|
||||
row for row in batch_rows
|
||||
if row.set_name in set_to_group
|
||||
]
|
||||
|
||||
# logger.debug(f"Processing batch {i // batch_size + 1}: {len(valid_rows)} valid rows")
|
||||
|
||||
if valid_rows:
|
||||
# Pass the entire set_to_group mapping
|
||||
self.create_tcgplayer_cards_batch(valid_rows, set_to_group)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load export CSV: {e}")
|
||||
# set file upload to failed
|
||||
if file_id:
|
||||
with db_transaction(self.db):
|
||||
file = self.db.query(File).filter(File.id == file_id).first()
|
||||
if file:
|
||||
file.status = 'failed'
|
||||
self.db.add(file)
|
||||
raise
|
||||
finally:
|
||||
if file_id:
|
||||
with db_transaction(self.db):
|
||||
file = self.db.query(File).filter(File.id == file_id).first()
|
||||
if file:
|
||||
file.status = 'completed'
|
||||
self.db.add(file)
|
||||
|
||||
|
||||
def get_card_tcgplayer_from_manabox_row(self, card: ManaboxRow, group_id: int) -> CardTCGPlayer:
|
||||
# Expanded rarity mapping
|
||||
mb_to_tcg_rarity_mapping = {
|
||||
"common": "C",
|
||||
"uncommon": "U",
|
||||
"rare": "R",
|
||||
"mythic": "M",
|
||||
"special": "S"
|
||||
}
|
||||
|
||||
# Mapping from Manabox condition+foil to TCGPlayer condition
|
||||
mb_to_tcg_condition_mapping = {
|
||||
("near_mint", "foil"): "Near Mint Foil",
|
||||
("near_mint", "normal"): "Near Mint",
|
||||
("near_mint", "etched"): "Near Mint Foil"
|
||||
}
|
||||
|
||||
# Get TCGPlayer condition from Manabox condition+foil combination
|
||||
tcg_condition = mb_to_tcg_condition_mapping.get((card.condition, card.foil))
|
||||
if tcg_condition is None:
|
||||
logger.error(f"Unsupported condition/foil combination: {card.condition}, {card.foil}")
|
||||
logger.error(f"Card details: name={card.name}, set_name={card.set_name}, collector_number={card.collector_number}")
|
||||
return None
|
||||
|
||||
# Get TCGPlayer rarity from Manabox rarity
|
||||
tcg_rarity = mb_to_tcg_rarity_mapping.get(card.rarity)
|
||||
if tcg_rarity is None:
|
||||
logger.error(f"Unsupported rarity: {card.rarity}")
|
||||
logger.error(f"Card details: name={card.name}, set_name={card.set_name}, collector_number={card.collector_number}")
|
||||
return None
|
||||
|
||||
# First query for matching products without rarity filter
|
||||
# debug
|
||||
# log everything in this query
|
||||
# remove letters from card.collector_number FOR JOIN ONLY
|
||||
join_collector_number = ''.join(filter(str.isdigit, card.collector_number))
|
||||
# logger.debug(f"Querying for card: {card.name}, {card.set_code}, {card.collector_number}, {tcg_condition}, {group_id}")
|
||||
base_query = self.db.query(CardTCGPlayer).filter(
|
||||
CardTCGPlayer.number == join_collector_number,
|
||||
CardTCGPlayer.condition == tcg_condition,
|
||||
CardTCGPlayer.group_id == group_id,
|
||||
CardTCGPlayer.rarity != "T" # TOKENS ARE NOT SUPPORTED CUZ BROKE LOL
|
||||
)
|
||||
|
||||
# logger.debug(f"Base query: {base_query.statement.compile(compile_kwargs={'literal_binds': True})}")
|
||||
|
||||
# Get all potential matches
|
||||
products = base_query.all()
|
||||
|
||||
# If no products found, return None
|
||||
if not products:
|
||||
logger.error(f"No matching TCGPlayer product found for card {card.name} ({card.set_code} {card.collector_number})")
|
||||
return None
|
||||
|
||||
# Look for an exact match including rarity, unless the TCGPlayer product is a land
|
||||
for product in products:
|
||||
if product.rarity == "L" or product.rarity == tcg_rarity:
|
||||
return product
|
||||
|
||||
# ignore rarity, just make sure only one product is returned
|
||||
if len(products) > 1:
|
||||
# try to match on name before failing
|
||||
for product in products:
|
||||
if product.product_name == card.name:
|
||||
return product
|
||||
elif len(products) == 1:
|
||||
return products[0]
|
||||
|
||||
logger.error(f"Multiple matching TCGPlayer products found for card {card.name} ({card.set_code} {card.collector_number})")
|
||||
return None
|
||||
|
||||
# If we got here, we found products but none matched our rarity criteria
|
||||
# logger.error(f"No matching TCGPlayer product with correct rarity found for card {card.name} {card.rarity} {group_id} ({card.set_name} {card.collector_number})")
|
||||
# return None
|
||||
|
||||
def get_pricing_export_for_all_products(self) -> File:
|
||||
"""
|
||||
"""
|
||||
DEBUG = False
|
||||
if DEBUG:
|
||||
logger.debug("DEBUG: Using existing pricing export file")
|
||||
file = self.db.query(File).filter(File.type == 'tcgplayer_pricing_export').first()
|
||||
if file:
|
||||
return file
|
||||
try:
|
||||
all_group_ids = self.db.query(TCGPlayerGroups.group_id).all()
|
||||
all_group_ids = [str(group_id) for group_id, in all_group_ids]
|
||||
export_csv = self._get_export_csv(all_group_ids)
|
||||
export_csv_file = self.file_service.create_file(export_csv, CreateFileRequest(
|
||||
source="tcgplayer",
|
||||
type="tcgplayer_pricing_export",
|
||||
filename="tcgplayer_pricing_export.csv"
|
||||
))
|
||||
return export_csv_file
|
||||
except SQLAlchemyError as e:
|
||||
raise RuntimeError(f"Failed to retrieve group IDs: {str(e)}")
|
||||
|
||||
def load_tcgplayer_cards(self, file_content):
|
||||
try:
|
||||
# load to card tcgplayer
|
||||
self.load_export_csv_to_card_tcgplayer(file_content)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load prices: {e}")
|
||||
raise
|
||||
|
||||
def open_box_cards_to_tcgplayer_inventory_df(self, open_box_ids: List[str]) -> pd.DataFrame:
|
||||
# Using sqlalchemy to group and sum quantities for duplicate TCGplayer IDs
|
||||
tcgcards = (self.db.query(
|
||||
CardTCGPlayer.product_id,
|
||||
CardTCGPlayer.tcgplayer_id,
|
||||
CardTCGPlayer.product_line,
|
||||
CardTCGPlayer.set_name,
|
||||
CardTCGPlayer.product_name,
|
||||
CardTCGPlayer.title,
|
||||
CardTCGPlayer.number,
|
||||
CardTCGPlayer.rarity,
|
||||
CardTCGPlayer.condition,
|
||||
func.sum(OpenBoxCard.quantity).label('quantity')
|
||||
)
|
||||
.filter(OpenBoxCard.open_box_id.in_(open_box_ids))
|
||||
.join(CardTCGPlayer, OpenBoxCard.card_id == CardTCGPlayer.product_id)
|
||||
.group_by(
|
||||
CardTCGPlayer.tcgplayer_id,
|
||||
CardTCGPlayer.product_id,
|
||||
CardTCGPlayer.product_line,
|
||||
CardTCGPlayer.set_name,
|
||||
CardTCGPlayer.product_name,
|
||||
CardTCGPlayer.title,
|
||||
CardTCGPlayer.number,
|
||||
CardTCGPlayer.rarity,
|
||||
CardTCGPlayer.condition
|
||||
)
|
||||
.all())
|
||||
|
||||
if not tcgcards:
|
||||
return None
|
||||
|
||||
# Create dataframe directly from the query results
|
||||
df = pd.DataFrame(tcgcards,
|
||||
columns=['product_id', 'tcgplayer_id', 'product_line', 'set_name', 'product_name',
|
||||
'title', 'number', 'rarity', 'condition', 'quantity'])
|
||||
|
||||
# Add empty columns
|
||||
df['Total Quantity'] = ''
|
||||
df['Add to Quantity'] = df['quantity']
|
||||
df['TCG Marketplace Price'] = ''
|
||||
df['Photo URL'] = ''
|
||||
|
||||
# Rename columns
|
||||
df = df.rename(columns={
|
||||
'tcgplayer_id': 'TCGplayer Id',
|
||||
'product_line': 'Product Line',
|
||||
'set_name': 'Set Name',
|
||||
'product_name': 'Product Name',
|
||||
'title': 'Title',
|
||||
'number': 'Number',
|
||||
'rarity': 'Rarity',
|
||||
'condition': 'Condition'
|
||||
})
|
||||
|
||||
return df
|
||||
|
||||
|
||||
|
521
app/services/tcgplayer_api.py
Normal file
521
app/services/tcgplayer_api.py
Normal file
@ -0,0 +1,521 @@
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from app.db.models import Orders, OrderProducts, CardTCGPlayer, CardManabox, APIPricing, TCGPlayerInventory
|
||||
from app.services.util._requests import RequestsUtil
|
||||
from app.services.util._docker import DockerUtil
|
||||
from app.db.utils import db_transaction
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from uuid import uuid4 as uuid
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from weasyprint import HTML
|
||||
import json
|
||||
import time
|
||||
import re
|
||||
import csv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class TCGPlayerAPIConfig:
|
||||
"""Configuration for TCGPlayer API"""
|
||||
ORDER_BASE_URL: str = "https://order-management-api.tcgplayer.com/orders"
|
||||
API_VERSION: str = "?api-version=2.0"
|
||||
SELLER_KEY: str = "e576ed4c"
|
||||
|
||||
class TCGPlayerAPIService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.docker_util = DockerUtil()
|
||||
self.requests_util = RequestsUtil()
|
||||
self.is_in_docker = self.docker_util.is_in_docker()
|
||||
self.config = TCGPlayerAPIConfig()
|
||||
self.cookies = self.get_cookies()
|
||||
self.session = None
|
||||
self.template_dir = "/app/app/assets/templates"
|
||||
self.env = Environment(loader=FileSystemLoader(self.template_dir))
|
||||
self.address_label_template = self.env.get_template("address_label.html")
|
||||
self.return_address_png = "file:///app/app/assets/images/ccrcardsaddress.png"
|
||||
|
||||
def get_cookies(self) -> dict:
|
||||
if self.is_in_docker:
|
||||
return self.requests_util.get_tcgplayer_cookies_from_file()
|
||||
else:
|
||||
return self.requests_util.get_tcgplayer_browser_cookies()
|
||||
|
||||
def get_order(self, order_id: str) -> dict:
|
||||
url = f"{self.config.ORDER_BASE_URL}/{order_id}{self.config.API_VERSION}"
|
||||
response = self.requests_util.send_request(url, method='GET', cookies=self.cookies)
|
||||
if response:
|
||||
return response.json()
|
||||
return None
|
||||
|
||||
def get_orders(self, size: int = 25) -> dict:
|
||||
url = f"{self.config.ORDER_BASE_URL}/search{self.config.API_VERSION}"
|
||||
payload = {
|
||||
"searchRange": "LastThreeMonths",
|
||||
"filters": {
|
||||
"sellerKey": self.config.SELLER_KEY
|
||||
},
|
||||
"sortBy": [
|
||||
{"sortingType": "orderStatus", "direction": "ascending"},
|
||||
{"sortingType": "orderDate", "direction": "descending"}
|
||||
],
|
||||
"from": 0,
|
||||
"size": size
|
||||
}
|
||||
response = self.requests_util.send_request(url, method='POST', cookies=self.cookies, json=payload)
|
||||
if response:
|
||||
return response.json()
|
||||
return None
|
||||
|
||||
def get_product_ids_from_sku(self, sku_ids: list[str]) -> dict:
|
||||
"""Get product IDs from TCGPlayer SKU IDs"""
|
||||
# convert SKU IDs to integers
|
||||
sku_ids = [int(sku_id) for sku_id in sku_ids]
|
||||
tcg_cards = self.db.query(CardTCGPlayer).filter(CardTCGPlayer.tcgplayer_id.in_(sku_ids)).all()
|
||||
return {str(card.tcgplayer_id): card.product_id for card in tcg_cards}
|
||||
|
||||
def save_order(self, order: dict):
|
||||
# check if order exists by order number
|
||||
order_number = order['orderNumber']
|
||||
existing_order = self.db.query(Orders).filter(Orders.order_id == order_number).first()
|
||||
if existing_order:
|
||||
logger.info(f"Order {order_number} already exists in database")
|
||||
return existing_order
|
||||
transaction = order['transaction']
|
||||
shipping = order['shippingAddress']
|
||||
products = order['products']
|
||||
with db_transaction(self.db):
|
||||
db_order = Orders(
|
||||
id = str(uuid()),
|
||||
order_id=order_number,
|
||||
buyer_name=order['buyerName'],
|
||||
recipient_name=shipping['recipientName'],
|
||||
recipient_address_one=shipping['addressOne'],
|
||||
recipient_address_two=shipping['addressTwo'] if 'addressTwo' in shipping else '',
|
||||
recipient_city=shipping['city'],
|
||||
recipient_state=shipping['territory'],
|
||||
recipient_zip=shipping['postalCode'],
|
||||
recipient_country=shipping['country'],
|
||||
order_date=order['createdAt'],
|
||||
status=order['status'],
|
||||
num_products=len(products),
|
||||
num_cards=sum([product['quantity'] for product in products]),
|
||||
product_amount=transaction['productAmount'],
|
||||
shipping_amount=transaction['shippingAmount'],
|
||||
gross_amount=transaction['grossAmount'],
|
||||
fee_amount=transaction['feeAmount'],
|
||||
net_amount=transaction['netAmount'],
|
||||
direct_fee_amount=transaction['directFeeAmount']
|
||||
)
|
||||
self.db.add(db_order)
|
||||
self.db.flush()
|
||||
|
||||
product_ids = [product['skuId'] for product in products]
|
||||
sku_to_product_id_mapping = self.get_product_ids_from_sku(product_ids)
|
||||
order_products = []
|
||||
for product in products:
|
||||
product_id = sku_to_product_id_mapping.get(product['skuId'])
|
||||
if product_id:
|
||||
order_products.append(
|
||||
OrderProducts(
|
||||
id=str(uuid()),
|
||||
order_id=db_order.id,
|
||||
product_id=product_id,
|
||||
quantity=product['quantity'],
|
||||
unit_price=product['unitPrice']
|
||||
)
|
||||
)
|
||||
self.db.add_all(order_products)
|
||||
return db_order
|
||||
|
||||
def process_orders_task(self):
|
||||
# get last 25 orders from tcgplayer
|
||||
orders = self.get_orders(size=100)
|
||||
if orders:
|
||||
# get list of order ids
|
||||
order_ids = [order['orderNumber'] for order in orders['orders']]
|
||||
# get a list of order ids that are not in the database
|
||||
existing_orders = self.db.query(Orders).filter(Orders.order_id.in_(order_ids)).all()
|
||||
existing_order_ids = [order.order_id for order in existing_orders]
|
||||
# get a list of order ids that are not in the database
|
||||
new_order_ids = [order_id for order_id in order_ids if order_id not in existing_order_ids]
|
||||
# process new orders
|
||||
processed_orders = []
|
||||
if new_order_ids:
|
||||
logger.info(f"Processing {len(new_order_ids)} new orders")
|
||||
new_orders = [order for order in orders['orders'] if order['orderNumber'] in new_order_ids]
|
||||
for new_order in new_orders:
|
||||
order = self.get_order(new_order['orderNumber'])
|
||||
self.save_order(order)
|
||||
processed_orders.append(order['orderNumber'])
|
||||
logger.info(f"Processed {len(processed_orders)} new orders")
|
||||
return processed_orders
|
||||
else:
|
||||
logger.info("No new orders to process")
|
||||
|
||||
def get_scryfall_data(self, scryfall_id: str):
|
||||
url = f"https://api.scryfall.com/cards/{scryfall_id}?format=json"
|
||||
response = self.requests_util.bare_request(url, method='GET')
|
||||
return response
|
||||
|
||||
def get_tcgplayer_pricing_data(self, tcgplayer_id: str):
|
||||
if not self.session:
|
||||
self.session = self.requests_util.get_session()
|
||||
response = self.session.get("https://tcgplayer.com")
|
||||
headers = {
|
||||
'accept': 'application/json, text/plain, */*',
|
||||
'accept-language': 'en-US,en;q=0.8',
|
||||
'priority': 'u=1, i',
|
||||
'sec-ch-ua': '"Not(A:Brand";v="99", "Brave";v="133", "Chromium";v="133"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"macOS"',
|
||||
'sec-fetch-dest': 'empty',
|
||||
'sec-fetch-mode': 'cors',
|
||||
'sec-fetch-site': 'same-site',
|
||||
'sec-gpc': '1',
|
||||
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0 Safari/537.36'
|
||||
}
|
||||
url = f"https://mp-search-api.tcgplayer.com/v2/product/{tcgplayer_id}/details?mpfev=3279"
|
||||
response = self.session.get(url, headers=headers)
|
||||
self.requests_util.previous_request_time = datetime.now()
|
||||
return response
|
||||
|
||||
# pricing
|
||||
def get_tcgplayer_pricing_data_for_product(self, product_id: str):
|
||||
# get tcgplayer pricing data for a single card by product id
|
||||
# product_id to manabox card
|
||||
manabox_card = self.db.query(CardManabox).filter(CardManabox.product_id == product_id).first()
|
||||
tcgplayer_card = self.db.query(CardTCGPlayer).filter(CardTCGPlayer.product_id == product_id).first()
|
||||
if not manabox_card or not tcgplayer_card:
|
||||
logger.warning(f"Card with product id {product_id} missing in either Manabox or TCGPlayer")
|
||||
return None
|
||||
mbfoil = manabox_card.foil
|
||||
if str.lower(mbfoil) == 'foil':
|
||||
logger.warning(f"Card with product id {product_id} is foil, skipping")
|
||||
return None
|
||||
# get scryfall id, tcgplayer id, and tcgplayer sku
|
||||
scryfall_id = manabox_card.scryfall_id
|
||||
tcgplayer_sku = tcgplayer_card.tcgplayer_id
|
||||
tcgplayer_id = self.get_scryfall_data(scryfall_id).json().get('tcgplayer_id')
|
||||
tcgplayer_pricing = self.get_tcgplayer_pricing_data(tcgplayer_id)
|
||||
if not tcgplayer_pricing:
|
||||
logger.warning(f"TCGPlayer pricing data not found for product id {product_id}")
|
||||
return None
|
||||
else:
|
||||
logger.info(f"TCGPlayer pricing data found for product id {product_id}")
|
||||
return tcgplayer_pricing.json()
|
||||
|
||||
def save_tcgplayer_pricing_data(self, product_id: str, pricing_data: dict):
|
||||
# convert to json
|
||||
pricing_data_json = json.dumps(pricing_data)
|
||||
with db_transaction(self.db):
|
||||
pricing_record = APIPricing(
|
||||
id=str(uuid()),
|
||||
product_id=product_id,
|
||||
pricing_data=str(pricing_data_json)
|
||||
)
|
||||
self.db.add(pricing_record)
|
||||
|
||||
def cron_tcgplayer_api_pricing(self):
|
||||
# Join both tables but retrieve both objects
|
||||
results = self.db.query(TCGPlayerInventory, CardTCGPlayer).join(
|
||||
CardTCGPlayer,
|
||||
TCGPlayerInventory.tcgplayer_id == CardTCGPlayer.tcgplayer_id
|
||||
).all()
|
||||
|
||||
for inventory, card in results:
|
||||
# Now use card.product_id (from CardTCGPlayer)
|
||||
pricing_data = self.get_tcgplayer_pricing_data_for_product(card.product_id)
|
||||
if pricing_data:
|
||||
self.save_tcgplayer_pricing_data(card.product_id, pricing_data)
|
||||
|
||||
def get_packing_slip_pdf_for_orders(self, order_ids: list[str]):
|
||||
url = f"{self.config.ORDER_BASE_URL}/packing-slips/export{self.config.API_VERSION}"
|
||||
payload = {
|
||||
"sortingType": "byRelease",
|
||||
"format": "default",
|
||||
"timezoneOffset": -4,
|
||||
"orderNumbers": order_ids
|
||||
}
|
||||
response = self.requests_util.send_request(url, method='POST', cookies=self.cookies, json=payload)
|
||||
if response:
|
||||
# get filename from response headers
|
||||
header = response.headers.get('Content-Disposition', '')
|
||||
match = re.search(r'filename="?([^";]+)"?', header)
|
||||
filename = match.group(1) if match else f'packingslip{datetime.now().strftime("%Y%m%d_%H%M%S")}.pdf'
|
||||
output_filename = f'/app/tmp/{filename}'
|
||||
|
||||
# save file to disk
|
||||
with open(output_filename, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
return output_filename
|
||||
|
||||
def get_pull_sheet_for_orders(self, order_ids: list[str]):
|
||||
url = f"{self.config.ORDER_BASE_URL}/pull-sheets/export{self.config.API_VERSION}"
|
||||
payload = {
|
||||
"orderNumbers": order_ids,
|
||||
"timezoneOffset": -4,
|
||||
}
|
||||
response = self.requests_util.send_request(url, method='POST', cookies=self.cookies, json=payload)
|
||||
if response:
|
||||
# get filename from response headers
|
||||
header = response.headers.get('Content-Disposition', '')
|
||||
match = re.search(r'filename="?([^";]+)"?', header)
|
||||
filename = match.group(1) if match else f'packingslip{datetime.now().strftime("%Y%m%d_%H%M%S")}.pdf'
|
||||
output_filename = f'/app/tmp/{filename}'
|
||||
# save file to disk
|
||||
with open(output_filename, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
return output_filename
|
||||
|
||||
def get_address_labels_csv(self, order_ids: list[str]):
|
||||
url = f"{self.config.ORDER_BASE_URL}/shipping/export{self.config.API_VERSION}"
|
||||
payload = {
|
||||
"orderNumbers": order_ids,
|
||||
"timezoneOffset": -4
|
||||
}
|
||||
response = self.requests_util.send_request(url, method='POST', cookies=self.cookies, json=payload)
|
||||
if response:
|
||||
# get filename from response headers
|
||||
header = response.headers.get('Content-Disposition', '')
|
||||
match = re.search(r'filename="?([^";]+)"?', header)
|
||||
filename = match.group(1) if match else f'shipping{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv'
|
||||
output_filename = f'/app/tmp/{filename}'
|
||||
# save file to disk
|
||||
with open(output_filename, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
return output_filename
|
||||
|
||||
def get_address_labels_pdf(self, order_ids: list[str]):
|
||||
shipping_csv_filename = self.get_address_labels_csv(order_ids)
|
||||
with open(shipping_csv_filename, 'r') as f:
|
||||
reader = csv.DictReader(f)
|
||||
orders = {}
|
||||
for row in reader:
|
||||
order_id = row.pop('Order #')
|
||||
orders[order_id] = row
|
||||
|
||||
labels_html = []
|
||||
|
||||
for order_id in orders:
|
||||
order = orders[order_id]
|
||||
#if float(order['Value of Products']) >49.99:
|
||||
#continue
|
||||
# Extract relevant information from the order
|
||||
order_info = {
|
||||
"recipient_name": order['FirstName'] + ' ' + order['LastName'],
|
||||
"address_line1": order['Address1'],
|
||||
"address_line2": order['Address2'] if 'Address2' in order else '',
|
||||
"city": order['City'],
|
||||
"state": order['State'],
|
||||
"zip_code": order['PostalCode'],
|
||||
"return_address_path": self.return_address_png
|
||||
}
|
||||
|
||||
# Render the label HTML using the template
|
||||
labels_html.append(self.address_label_template.render(order_info))
|
||||
|
||||
if labels_html:
|
||||
# Combine the rendered labels into one HTML string
|
||||
full_html = "<html><body>" + "\n".join(labels_html) + "</body></html>"
|
||||
|
||||
# Generate a unique output filename with a timestamp
|
||||
output_filename = f'/app/tmp/address_labels_{datetime.now().strftime("%Y%m%d_%H%M%S")}.pdf'
|
||||
|
||||
# Generate the PDF from the HTML string
|
||||
HTML(string=full_html).write_pdf(output_filename)
|
||||
|
||||
return output_filename
|
||||
else:
|
||||
print("No orders found or no valid labels generated.")
|
||||
return None
|
||||
|
||||
def process_open_orders(self, order_ids: list[str]=None):
|
||||
# get all open orders
|
||||
url = f"{self.config.ORDER_BASE_URL}/search{self.config.API_VERSION}"
|
||||
"""{"searchRange":"LastThreeMonths","filters":{"sellerKey":"e576ed4c","orderStatuses":["Processing","ReadyToShip","Received","Pulling","ReadyForPickup"],"fulfillmentTypes":["Normal"]},"sortBy":[{"sortingType":"orderStatus","direction":"ascending"},{"sortingType":"orderDate","direction":"ascending"}],"from":0,"size":25}"""
|
||||
payload = {
|
||||
"searchRange": "LastThreeMonths",
|
||||
"filters": {
|
||||
"sellerKey": self.config.SELLER_KEY,
|
||||
"orderStatuses": ["Processing", "ReadyToShip", "Received", "Pulling", "ReadyForPickup"],
|
||||
"fulfillmentTypes": ["Normal"]
|
||||
},
|
||||
"sortBy": [
|
||||
{"sortingType": "orderStatus", "direction": "ascending"},
|
||||
{"sortingType": "orderDate", "direction": "ascending"}
|
||||
],
|
||||
"from": 0,
|
||||
"size": 100
|
||||
}
|
||||
response = self.requests_util.send_request(url, method='POST', cookies=self.cookies, json=payload)
|
||||
if response:
|
||||
orders = response.json()
|
||||
if orders and 'orders' in orders:
|
||||
if order_ids is None:
|
||||
order_ids = [order['orderNumber'] for order in orders['orders']]
|
||||
# get packing slip pdf
|
||||
packing_slip_filename = self.get_packing_slip_pdf_for_orders(order_ids)
|
||||
# get pull sheet pdf
|
||||
pull_sheet_filename = self.get_pull_sheet_for_orders(order_ids)
|
||||
# get address labels pdf
|
||||
address_labels_filename = self.get_address_labels_pdf(order_ids)
|
||||
with open(packing_slip_filename, 'rb') as packing_slip_file, \
|
||||
open(pull_sheet_filename, 'rb') as pull_sheet_file, \
|
||||
open(address_labels_filename, 'rb') as address_labels_file:
|
||||
files = [
|
||||
#packing_slip_file,
|
||||
# pull_sheet_file,
|
||||
address_labels_file
|
||||
]
|
||||
# request post pdfs
|
||||
for file in files:
|
||||
self.requests_util.bare_request(
|
||||
url="http://192.168.1.110:8000/upload",
|
||||
method='POST',
|
||||
files={'file': file}
|
||||
)
|
||||
time.sleep(10)
|
||||
return order_ids
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# this one contains nearly everything, use it first
|
||||
# what does score mean? - totally ignore score, it seems related to price and changes based on what is on the page. probably some psy op shit to get you to buy expensive stuff, not useful for us
|
||||
# can i get volatility from here?
|
||||
# no historical data here
|
||||
"""
|
||||
curl 'https://mp-search-api.tcgplayer.com/v2/product/615745/details?mpfev=3279' \
|
||||
-H 'accept: application/json, text/plain, */*' \
|
||||
-H 'accept-language: en-US,en;q=0.8' \
|
||||
-b 'tcgpartner=PK=TRADECARDS&M=1; valid=set=true; product-display-settings=sort=price+shipping&size=10; OAuthLoginSessionId=63b1a89d-1ac2-43f7-8e79-55a9ca5e761d; __RequestVerificationToken_L2FkbWlu0=Lw1sfWh823UeJ7zRux0b1ZTI4Vg4i_dFt97a55aQpf-qBURVuwWDCJyuCxSwgLNLe9nPlfDSc1AMV5nyqhY4Q4jurxs1; spDisabledUIFeatures=orders; SellerProximity=ZipCode=&MaxSellerDistance=1000&IsActive=false; tcg-uuid=613192dc-ecf6-481a-bec1-afdee8686db7; LastSeller=e576ed4c; __RequestVerificationToken=VFv72VLK6McJVzthg8O-41p7BNkdoW2jQAlDAu-ylO39qfzCddRi2-7bWiH4qloc8Vo_ZftOAAa5OhXL3OByFHIdlwY1; TCGAuthTicket_Production=270B0566400C905C51DEAD644E3CDBD634ECBCCC796B1F77717F461EE104FCE101CFAD2A8458319330A0931018A99214D4EA5601E7551E25E2069ACA550BB71775C0A04F30724E2C4E262CB167EAC2C2EB05D15F9EA08363FC6455B94654F1F110CF079E24201C3B8CEF26762423D8CAA71DDF7B; ASP.NET_SessionId=5ycv15jf0mon3l5adodmkog5; StoreSaveForLater_PRODUCTION=SFLK=a167bf88521f4d0fbeb7497a7ed74629&Ignore=false; TCG_VisitorKey=81fe992f-9a12-4926-a417-7815c4f94edd; setting=CD=US&M=1; SearchSortSettings=M=1&ProductSortOption=MinPrice&ProductSortDesc=True&PriceSortOption=Shipping&ProductResultDisplay=grid; tcg_analytics_previousPageData=%7B%22title%22%3A%22Seller%20Feedback%22%2C%22href%22%3A%22https%3A%2F%2Fshop.tcgplayer.com%2Fsellerfeedback%2Fbe27fef9%22%7D; fileDownloadToken=1740499419709; StoreCart_PRODUCTION=CK=b4f8aff616974a12a6b2811129b81ee2&Ignore=false; tracking-preferences={%22version%22:1%2C%22destinations%22:{%22Actions%20Amplitude%22:false%2C%22AdWords%22:false%2C%22Google%20AdWords%20New%22:false%2C%22Google%20Enhanced%20Conversions%22:false%2C%22Google%20Tag%20Manager%22:false%2C%22Impact%20Partnership%20Cloud%22:false%2C%22Optimizely%22:false}%2C%22custom%22:{%22advertising%22:false%2C%22functional%22:false%2C%22marketingAndAnalytics%22:false}}; tcg-segment-session=1740595460137%257C1740595481177' \
|
||||
-H 'origin: https://www.tcgplayer.com' \
|
||||
-H 'priority: u=1, i' \
|
||||
-H 'referer: https://www.tcgplayer.com/' \
|
||||
-H 'sec-ch-ua: "Not(A:Brand";v="99", "Brave";v="133", "Chromium";v="133"' \
|
||||
-H 'sec-ch-ua-mobile: ?0' \
|
||||
-H 'sec-ch-ua-platform: "macOS"' \
|
||||
-H 'sec-fetch-dest: empty' \
|
||||
-H 'sec-fetch-mode: cors' \
|
||||
-H 'sec-fetch-site: same-site' \
|
||||
-H 'sec-gpc: 1' \
|
||||
-H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36'
|
||||
"""
|
||||
|
||||
# get volatility also
|
||||
"""
|
||||
curl 'https://mpgateway.tcgplayer.com/v1/pricepoints/marketprice/skus/8395586/volatility?mpfev=3279' \
|
||||
-H 'accept: application/json, text/plain, */*' \
|
||||
-H 'accept-language: en-US,en;q=0.8' \
|
||||
-b 'tcgpartner=PK=TRADECARDS&M=1; valid=set=true; product-display-settings=sort=price+shipping&size=10; OAuthLoginSessionId=63b1a89d-1ac2-43f7-8e79-55a9ca5e761d; __RequestVerificationToken_L2FkbWlu0=Lw1sfWh823UeJ7zRux0b1ZTI4Vg4i_dFt97a55aQpf-qBURVuwWDCJyuCxSwgLNLe9nPlfDSc1AMV5nyqhY4Q4jurxs1; spDisabledUIFeatures=orders; SellerProximity=ZipCode=&MaxSellerDistance=1000&IsActive=false; tcg-uuid=613192dc-ecf6-481a-bec1-afdee8686db7; LastSeller=e576ed4c; __RequestVerificationToken=VFv72VLK6McJVzthg8O-41p7BNkdoW2jQAlDAu-ylO39qfzCddRi2-7bWiH4qloc8Vo_ZftOAAa5OhXL3OByFHIdlwY1; TCGAuthTicket_Production=270B0566400C905C51DEAD644E3CDBD634ECBCCC796B1F77717F461EE104FCE101CFAD2A8458319330A0931018A99214D4EA5601E7551E25E2069ACA550BB71775C0A04F30724E2C4E262CB167EAC2C2EB05D15F9EA08363FC6455B94654F1F110CF079E24201C3B8CEF26762423D8CAA71DDF7B; ASP.NET_SessionId=5ycv15jf0mon3l5adodmkog5; StoreSaveForLater_PRODUCTION=SFLK=a167bf88521f4d0fbeb7497a7ed74629&Ignore=false; TCG_VisitorKey=81fe992f-9a12-4926-a417-7815c4f94edd; setting=CD=US&M=1; tcg_analytics_previousPageData=%7B%22title%22%3A%22Seller%20Feedback%22%2C%22href%22%3A%22https%3A%2F%2Fshop.tcgplayer.com%2Fsellerfeedback%2Fbe27fef9%22%7D; fileDownloadToken=1740499419709; StoreCart_PRODUCTION=CK=b4f8aff616974a12a6b2811129b81ee2&Ignore=false; tracking-preferences={%22version%22:1%2C%22destinations%22:{%22Actions%20Amplitude%22:false%2C%22AdWords%22:false%2C%22Google%20AdWords%20New%22:false%2C%22Google%20Enhanced%20Conversions%22:false%2C%22Google%20Tag%20Manager%22:false%2C%22Impact%20Partnership%20Cloud%22:false%2C%22Optimizely%22:false}%2C%22custom%22:{%22advertising%22:false%2C%22functional%22:false%2C%22marketingAndAnalytics%22:false}}; SearchCriteria=M=1&WantVerifiedSellers=False&WantDirect=False&WantSellersInCart=False; SearchSortSettings=M=1&ProductSortOption=Sales&ProductSortDesc=False&PriceSortOption=Shipping&ProductResultDisplay=grid; tcg-segment-session=1740597680921%257C1740598418227' \
|
||||
-H 'origin: https://www.tcgplayer.com' \
|
||||
-H 'priority: u=1, i' \
|
||||
-H 'referer: https://www.tcgplayer.com/' \
|
||||
-H 'sec-ch-ua: "Not(A:Brand";v="99", "Brave";v="133", "Chromium";v="133"' \
|
||||
-H 'sec-ch-ua-mobile: ?0' \
|
||||
-H 'sec-ch-ua-platform: "macOS"' \
|
||||
-H 'sec-fetch-dest: empty' \
|
||||
-H 'sec-fetch-mode: cors' \
|
||||
-H 'sec-fetch-site: same-site' \
|
||||
-H 'sec-gpc: 1' \
|
||||
-H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36'
|
||||
"""
|
||||
|
||||
# handle historical data later
|
||||
|
||||
|
||||
# detailed range quarter - detailed pricing info for the last quarter. seems simple
|
||||
"""
|
||||
|
||||
"""
|
||||
# listings - lots of stuff here
|
||||
"""
|
||||
QUANTITY OVERVIEW
|
||||
|
||||
curl 'https://mp-search-api.tcgplayer.com/v1/product/615745/listings?mpfev=3279' \
|
||||
-H 'accept: application/json, text/plain, */*' \
|
||||
-H 'accept-language: en-US,en;q=0.8' \
|
||||
-H 'content-type: application/json' \
|
||||
-b 'tcgpartner=PK=TRADECARDS&M=1; valid=set=true; product-display-settings=sort=price+shipping&size=10; OAuthLoginSessionId=63b1a89d-1ac2-43f7-8e79-55a9ca5e761d; __RequestVerificationToken_L2FkbWlu0=Lw1sfWh823UeJ7zRux0b1ZTI4Vg4i_dFt97a55aQpf-qBURVuwWDCJyuCxSwgLNLe9nPlfDSc1AMV5nyqhY4Q4jurxs1; spDisabledUIFeatures=orders; SellerProximity=ZipCode=&MaxSellerDistance=1000&IsActive=false; tcg-uuid=613192dc-ecf6-481a-bec1-afdee8686db7; LastSeller=e576ed4c; __RequestVerificationToken=VFv72VLK6McJVzthg8O-41p7BNkdoW2jQAlDAu-ylO39qfzCddRi2-7bWiH4qloc8Vo_ZftOAAa5OhXL3OByFHIdlwY1; TCGAuthTicket_Production=270B0566400C905C51DEAD644E3CDBD634ECBCCC796B1F77717F461EE104FCE101CFAD2A8458319330A0931018A99214D4EA5601E7551E25E2069ACA550BB71775C0A04F30724E2C4E262CB167EAC2C2EB05D15F9EA08363FC6455B94654F1F110CF079E24201C3B8CEF26762423D8CAA71DDF7B; ASP.NET_SessionId=5ycv15jf0mon3l5adodmkog5; StoreSaveForLater_PRODUCTION=SFLK=a167bf88521f4d0fbeb7497a7ed74629&Ignore=false; TCG_VisitorKey=81fe992f-9a12-4926-a417-7815c4f94edd; setting=CD=US&M=1; SearchSortSettings=M=1&ProductSortOption=MinPrice&ProductSortDesc=True&PriceSortOption=Shipping&ProductResultDisplay=grid; tcg_analytics_previousPageData=%7B%22title%22%3A%22Seller%20Feedback%22%2C%22href%22%3A%22https%3A%2F%2Fshop.tcgplayer.com%2Fsellerfeedback%2Fbe27fef9%22%7D; fileDownloadToken=1740499419709; StoreCart_PRODUCTION=CK=b4f8aff616974a12a6b2811129b81ee2&Ignore=false; tracking-preferences={%22version%22:1%2C%22destinations%22:{%22Actions%20Amplitude%22:false%2C%22AdWords%22:false%2C%22Google%20AdWords%20New%22:false%2C%22Google%20Enhanced%20Conversions%22:false%2C%22Google%20Tag%20Manager%22:false%2C%22Impact%20Partnership%20Cloud%22:false%2C%22Optimizely%22:false}%2C%22custom%22:{%22advertising%22:false%2C%22functional%22:false%2C%22marketingAndAnalytics%22:false}}; tcg-segment-session=1740595460137%257C1740595481430' \
|
||||
-H 'origin: https://www.tcgplayer.com' \
|
||||
-H 'priority: u=1, i' \
|
||||
-H 'referer: https://www.tcgplayer.com/' \
|
||||
-H 'sec-ch-ua: "Not(A:Brand";v="99", "Brave";v="133", "Chromium";v="133"' \
|
||||
-H 'sec-ch-ua-mobile: ?0' \
|
||||
-H 'sec-ch-ua-platform: "macOS"' \
|
||||
-H 'sec-fetch-dest: empty' \
|
||||
-H 'sec-fetch-mode: cors' \
|
||||
-H 'sec-fetch-site: same-site' \
|
||||
-H 'sec-gpc: 1' \
|
||||
-H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36' \
|
||||
--data-raw '{"filters":{"term":{"sellerStatus":"Live","channelId":0,"language":["English"],"direct-seller":true,"directProduct":true,"listingType":"standard"},"range":{"quantity":{"gte":1},"direct-inventory":{"gte":1}},"exclude":{"channelExclusion":0,"listingType":"custom"}},"from":0,"size":1,"context":{"shippingCountry":"US","cart":{}},"sort":{"field":"price+shipping","order":"asc"}}'
|
||||
|
||||
|
||||
AGGREGATION AND SOME SPECIFIC DATA IDK THIS MIGHT BE A GOOD ONE
|
||||
|
||||
curl 'https://mp-search-api.tcgplayer.com/v1/product/615745/listings?mpfev=3279' \
|
||||
-H 'accept: application/json, text/plain, */*' \
|
||||
-H 'accept-language: en-US,en;q=0.8' \
|
||||
-H 'content-type: application/json' \
|
||||
-b 'tcgpartner=PK=TRADECARDS&M=1; valid=set=true; product-display-settings=sort=price+shipping&size=10; OAuthLoginSessionId=63b1a89d-1ac2-43f7-8e79-55a9ca5e761d; __RequestVerificationToken_L2FkbWlu0=Lw1sfWh823UeJ7zRux0b1ZTI4Vg4i_dFt97a55aQpf-qBURVuwWDCJyuCxSwgLNLe9nPlfDSc1AMV5nyqhY4Q4jurxs1; spDisabledUIFeatures=orders; SellerProximity=ZipCode=&MaxSellerDistance=1000&IsActive=false; tcg-uuid=613192dc-ecf6-481a-bec1-afdee8686db7; LastSeller=e576ed4c; __RequestVerificationToken=VFv72VLK6McJVzthg8O-41p7BNkdoW2jQAlDAu-ylO39qfzCddRi2-7bWiH4qloc8Vo_ZftOAAa5OhXL3OByFHIdlwY1; TCGAuthTicket_Production=270B0566400C905C51DEAD644E3CDBD634ECBCCC796B1F77717F461EE104FCE101CFAD2A8458319330A0931018A99214D4EA5601E7551E25E2069ACA550BB71775C0A04F30724E2C4E262CB167EAC2C2EB05D15F9EA08363FC6455B94654F1F110CF079E24201C3B8CEF26762423D8CAA71DDF7B; ASP.NET_SessionId=5ycv15jf0mon3l5adodmkog5; StoreSaveForLater_PRODUCTION=SFLK=a167bf88521f4d0fbeb7497a7ed74629&Ignore=false; TCG_VisitorKey=81fe992f-9a12-4926-a417-7815c4f94edd; setting=CD=US&M=1; SearchSortSettings=M=1&ProductSortOption=MinPrice&ProductSortDesc=True&PriceSortOption=Shipping&ProductResultDisplay=grid; tcg_analytics_previousPageData=%7B%22title%22%3A%22Seller%20Feedback%22%2C%22href%22%3A%22https%3A%2F%2Fshop.tcgplayer.com%2Fsellerfeedback%2Fbe27fef9%22%7D; fileDownloadToken=1740499419709; StoreCart_PRODUCTION=CK=b4f8aff616974a12a6b2811129b81ee2&Ignore=false; tracking-preferences={%22version%22:1%2C%22destinations%22:{%22Actions%20Amplitude%22:false%2C%22AdWords%22:false%2C%22Google%20AdWords%20New%22:false%2C%22Google%20Enhanced%20Conversions%22:false%2C%22Google%20Tag%20Manager%22:false%2C%22Impact%20Partnership%20Cloud%22:false%2C%22Optimizely%22:false}%2C%22custom%22:{%22advertising%22:false%2C%22functional%22:false%2C%22marketingAndAnalytics%22:false}}; tcg-segment-session=1740595460137%257C1740595481430' \
|
||||
-H 'origin: https://www.tcgplayer.com' \
|
||||
-H 'priority: u=1, i' \
|
||||
-H 'referer: https://www.tcgplayer.com/' \
|
||||
-H 'sec-ch-ua: "Not(A:Brand";v="99", "Brave";v="133", "Chromium";v="133"' \
|
||||
-H 'sec-ch-ua-mobile: ?0' \
|
||||
-H 'sec-ch-ua-platform: "macOS"' \
|
||||
-H 'sec-fetch-dest: empty' \
|
||||
-H 'sec-fetch-mode: cors' \
|
||||
-H 'sec-fetch-site: same-site' \
|
||||
-H 'sec-gpc: 1' \
|
||||
-H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36' \
|
||||
--data-raw '{"filters":{"term":{"sellerStatus":"Live","channelId":0,"language":["English"]},"range":{"quantity":{"gte":1}},"exclude":{"channelExclusion":0}},"from":0,"size":10,"sort":{"field":"price+shipping","order":"asc"},"context":{"shippingCountry":"US","cart":{}},"aggregations":["listingType"]}'
|
||||
|
||||
|
||||
AGGREGATION OF RANDOM SHIT IDK
|
||||
|
||||
curl 'https://mp-search-api.tcgplayer.com/v1/product/615745/listings?mpfev=3279' \
|
||||
-H 'accept: application/json, text/plain, */*' \
|
||||
-H 'accept-language: en-US,en;q=0.8' \
|
||||
-H 'content-type: application/json' \
|
||||
-b 'tcgpartner=PK=TRADECARDS&M=1; valid=set=true; product-display-settings=sort=price+shipping&size=10; OAuthLoginSessionId=63b1a89d-1ac2-43f7-8e79-55a9ca5e761d; __RequestVerificationToken_L2FkbWlu0=Lw1sfWh823UeJ7zRux0b1ZTI4Vg4i_dFt97a55aQpf-qBURVuwWDCJyuCxSwgLNLe9nPlfDSc1AMV5nyqhY4Q4jurxs1; spDisabledUIFeatures=orders; SellerProximity=ZipCode=&MaxSellerDistance=1000&IsActive=false; tcg-uuid=613192dc-ecf6-481a-bec1-afdee8686db7; LastSeller=e576ed4c; __RequestVerificationToken=VFv72VLK6McJVzthg8O-41p7BNkdoW2jQAlDAu-ylO39qfzCddRi2-7bWiH4qloc8Vo_ZftOAAa5OhXL3OByFHIdlwY1; TCGAuthTicket_Production=270B0566400C905C51DEAD644E3CDBD634ECBCCC796B1F77717F461EE104FCE101CFAD2A8458319330A0931018A99214D4EA5601E7551E25E2069ACA550BB71775C0A04F30724E2C4E262CB167EAC2C2EB05D15F9EA08363FC6455B94654F1F110CF079E24201C3B8CEF26762423D8CAA71DDF7B; ASP.NET_SessionId=5ycv15jf0mon3l5adodmkog5; StoreSaveForLater_PRODUCTION=SFLK=a167bf88521f4d0fbeb7497a7ed74629&Ignore=false; TCG_VisitorKey=81fe992f-9a12-4926-a417-7815c4f94edd; setting=CD=US&M=1; SearchSortSettings=M=1&ProductSortOption=MinPrice&ProductSortDesc=True&PriceSortOption=Shipping&ProductResultDisplay=grid; tcg_analytics_previousPageData=%7B%22title%22%3A%22Seller%20Feedback%22%2C%22href%22%3A%22https%3A%2F%2Fshop.tcgplayer.com%2Fsellerfeedback%2Fbe27fef9%22%7D; fileDownloadToken=1740499419709; StoreCart_PRODUCTION=CK=b4f8aff616974a12a6b2811129b81ee2&Ignore=false; tracking-preferences={%22version%22:1%2C%22destinations%22:{%22Actions%20Amplitude%22:false%2C%22AdWords%22:false%2C%22Google%20AdWords%20New%22:false%2C%22Google%20Enhanced%20Conversions%22:false%2C%22Google%20Tag%20Manager%22:false%2C%22Impact%20Partnership%20Cloud%22:false%2C%22Optimizely%22:false}%2C%22custom%22:{%22advertising%22:false%2C%22functional%22:false%2C%22marketingAndAnalytics%22:false}}; tcg-segment-session=1740595460137%257C1740595481430' \
|
||||
-H 'origin: https://www.tcgplayer.com' \
|
||||
-H 'priority: u=1, i' \
|
||||
-H 'referer: https://www.tcgplayer.com/' \
|
||||
-H 'sec-ch-ua: "Not(A:Brand";v="99", "Brave";v="133", "Chromium";v="133"' \
|
||||
-H 'sec-ch-ua-mobile: ?0' \
|
||||
-H 'sec-ch-ua-platform: "macOS"' \
|
||||
-H 'sec-fetch-dest: empty' \
|
||||
-H 'sec-fetch-mode: cors' \
|
||||
-H 'sec-fetch-site: same-site' \
|
||||
-H 'sec-gpc: 1' \
|
||||
-H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36' \
|
||||
--data-raw '{"filters":{"term":{"condition":["Near Mint","Lightly Played","Moderately Played","Heavily Played","Damaged"],"printing":["Foil"],"language":["English"],"sellerStatus":"Live"},"range":{"quantity":{"gte":1}},"exclude":{"channelExclusion":0}},"context":{"shippingCountry":"US","cart":{}},"aggregations":["seller-key"],"size":0}'
|
||||
|
||||
|
||||
VOLATILITY
|
||||
|
||||
curl 'https://mpgateway.tcgplayer.com/v1/pricepoints/marketprice/skus/8547894/volatility?mpfev=3279' \
|
||||
-H 'accept: application/json, text/plain, */*' \
|
||||
-H 'accept-language: en-US,en;q=0.8' \
|
||||
-b 'tcgpartner=PK=TRADECARDS&M=1; valid=set=true; product-display-settings=sort=price+shipping&size=10; OAuthLoginSessionId=63b1a89d-1ac2-43f7-8e79-55a9ca5e761d; __RequestVerificationToken_L2FkbWlu0=Lw1sfWh823UeJ7zRux0b1ZTI4Vg4i_dFt97a55aQpf-qBURVuwWDCJyuCxSwgLNLe9nPlfDSc1AMV5nyqhY4Q4jurxs1; spDisabledUIFeatures=orders; SellerProximity=ZipCode=&MaxSellerDistance=1000&IsActive=false; tcg-uuid=613192dc-ecf6-481a-bec1-afdee8686db7; LastSeller=e576ed4c; __RequestVerificationToken=VFv72VLK6McJVzthg8O-41p7BNkdoW2jQAlDAu-ylO39qfzCddRi2-7bWiH4qloc8Vo_ZftOAAa5OhXL3OByFHIdlwY1; TCGAuthTicket_Production=270B0566400C905C51DEAD644E3CDBD634ECBCCC796B1F77717F461EE104FCE101CFAD2A8458319330A0931018A99214D4EA5601E7551E25E2069ACA550BB71775C0A04F30724E2C4E262CB167EAC2C2EB05D15F9EA08363FC6455B94654F1F110CF079E24201C3B8CEF26762423D8CAA71DDF7B; ASP.NET_SessionId=5ycv15jf0mon3l5adodmkog5; StoreSaveForLater_PRODUCTION=SFLK=a167bf88521f4d0fbeb7497a7ed74629&Ignore=false; TCG_VisitorKey=81fe992f-9a12-4926-a417-7815c4f94edd; setting=CD=US&M=1; SearchSortSettings=M=1&ProductSortOption=MinPrice&ProductSortDesc=True&PriceSortOption=Shipping&ProductResultDisplay=grid; tcg_analytics_previousPageData=%7B%22title%22%3A%22Seller%20Feedback%22%2C%22href%22%3A%22https%3A%2F%2Fshop.tcgplayer.com%2Fsellerfeedback%2Fbe27fef9%22%7D; fileDownloadToken=1740499419709; StoreCart_PRODUCTION=CK=b4f8aff616974a12a6b2811129b81ee2&Ignore=false; tracking-preferences={%22version%22:1%2C%22destinations%22:{%22Actions%20Amplitude%22:false%2C%22AdWords%22:false%2C%22Google%20AdWords%20New%22:false%2C%22Google%20Enhanced%20Conversions%22:false%2C%22Google%20Tag%20Manager%22:false%2C%22Impact%20Partnership%20Cloud%22:false%2C%22Optimizely%22:false}%2C%22custom%22:{%22advertising%22:false%2C%22functional%22:false%2C%22marketingAndAnalytics%22:false}}; tcg-segment-session=1740595460137%257C1740595481430' \
|
||||
-H 'origin: https://www.tcgplayer.com' \
|
||||
-H 'priority: u=1, i' \
|
||||
-H 'referer: https://www.tcgplayer.com/' \
|
||||
-H 'sec-ch-ua: "Not(A:Brand";v="99", "Brave";v="133", "Chromium";v="133"' \
|
||||
-H 'sec-ch-ua-mobile: ?0' \
|
||||
-H 'sec-ch-ua-platform: "macOS"' \
|
||||
-H 'sec-fetch-dest: empty' \
|
||||
-H 'sec-fetch-mode: cors' \
|
||||
-H 'sec-fetch-site: same-site' \
|
||||
-H 'sec-gpc: 1' \
|
||||
-H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36'
|
||||
"""
|
500
app/services/unholy_pricing.py
Normal file
500
app/services/unholy_pricing.py
Normal file
@ -0,0 +1,500 @@
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, List, Any
|
||||
import pandas as pd
|
||||
import logging
|
||||
from db.models import Product, Price
|
||||
from sqlalchemy.orm import Session
|
||||
from uuid import uuid4 as uuid
|
||||
from datetime import datetime
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from sqlalchemy import text
|
||||
from services.util._dataframe import DataframeUtil
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class PriceType(str, Enum):
|
||||
TCG_MARKET = 'tcg_market_price'
|
||||
TCG_DIRECT_LOW = 'tcg_direct_low'
|
||||
TCG_LOW_WITH_SHIPPING = 'tcg_low_price_with_shipping'
|
||||
TCG_LOW = 'tcg_low_price'
|
||||
TCG_MARKETPLACE = 'tcg_marketplace_price'
|
||||
MY_PRICE = 'my_price'
|
||||
|
||||
class PricingStrategy(str, Enum):
|
||||
DEFAULT = 'default'
|
||||
AGGRESSIVE = 'aggressive'
|
||||
CONSERVATIVE = 'conservative'
|
||||
|
||||
@dataclass
|
||||
class PriceRange:
|
||||
min_price: Decimal
|
||||
max_price: Decimal
|
||||
multiplier: Decimal
|
||||
ceiling_price: Optional[Decimal] = None
|
||||
include_shipping: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
# Convert all values to Decimal for precise calculations
|
||||
self.min_price = Decimal(str(self.min_price))
|
||||
self.max_price = Decimal(str(self.max_price))
|
||||
self.multiplier = Decimal(str(self.multiplier))
|
||||
if self.ceiling_price is not None:
|
||||
self.ceiling_price = Decimal(str(self.ceiling_price))
|
||||
|
||||
def contains_price(self, price: Decimal) -> bool:
|
||||
"""Check if a price falls within this range, inclusive of min, exclusive of max."""
|
||||
return self.min_price <= price < self.max_price
|
||||
|
||||
def calculate_price(self, base_price: Decimal) -> Decimal:
|
||||
"""Calculate the final price for this range, respecting ceiling."""
|
||||
calculated = base_price * self.multiplier
|
||||
if self.ceiling_price is not None:
|
||||
calculated = min(calculated, self.ceiling_price)
|
||||
return calculated.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
|
||||
|
||||
class PricingConfiguration:
|
||||
"""Centralized configuration for pricing rules and thresholds."""
|
||||
|
||||
# Price thresholds
|
||||
FLOOR_PRICE = Decimal('0.35')
|
||||
MAX_PRICE = Decimal('100000.00') # Safety cap for maximum price
|
||||
SHIPPING_THRESHOLD = Decimal('5.00')
|
||||
|
||||
# Multipliers
|
||||
FLOOR_MULT = Decimal('1.25')
|
||||
NEAR_FLOOR_MULT = Decimal('1.25')
|
||||
UNDER_FIVE_MULT = Decimal('1.25')
|
||||
FIVE_TO_TEN_MULT = Decimal('1.15')
|
||||
TEN_TO_TWENTYFIVE_MULT = Decimal('1.10')
|
||||
TWENTYFIVE_TO_FIFTY_MULT = Decimal('1.05')
|
||||
FIFTY_PLUS_MULT = Decimal('1.025')
|
||||
|
||||
# Price variance thresholds
|
||||
MAX_PRICE_VARIANCE = Decimal('0.50') # Maximum allowed variance between prices as a ratio
|
||||
|
||||
@classmethod
|
||||
def get_price_ranges(cls) -> list[PriceRange]:
|
||||
"""Get the list of price ranges with their respective rules."""
|
||||
return [
|
||||
PriceRange(
|
||||
min_price=Decimal('0'),
|
||||
max_price=cls.FLOOR_PRICE,
|
||||
multiplier=cls.FLOOR_MULT,
|
||||
include_shipping=False
|
||||
),
|
||||
PriceRange(
|
||||
min_price=cls.FLOOR_PRICE,
|
||||
max_price=Decimal('5'),
|
||||
multiplier=cls.UNDER_FIVE_MULT,
|
||||
ceiling_price=Decimal('4.99'),
|
||||
include_shipping=False
|
||||
),
|
||||
PriceRange(
|
||||
min_price=Decimal('5'),
|
||||
max_price=Decimal('10'),
|
||||
multiplier=cls.FIVE_TO_TEN_MULT,
|
||||
ceiling_price=Decimal('9.99'),
|
||||
include_shipping=True
|
||||
),
|
||||
PriceRange(
|
||||
min_price=Decimal('10'),
|
||||
max_price=Decimal('25'),
|
||||
multiplier=cls.TEN_TO_TWENTYFIVE_MULT,
|
||||
ceiling_price=Decimal('24.99'),
|
||||
include_shipping=True
|
||||
),
|
||||
PriceRange(
|
||||
min_price=Decimal('25'),
|
||||
max_price=Decimal('50'),
|
||||
multiplier=cls.TWENTYFIVE_TO_FIFTY_MULT,
|
||||
ceiling_price=Decimal('49.99'),
|
||||
include_shipping=True
|
||||
),
|
||||
PriceRange(
|
||||
min_price=Decimal('50'),
|
||||
max_price=cls.MAX_PRICE,
|
||||
multiplier=cls.FIFTY_PLUS_MULT,
|
||||
include_shipping=True
|
||||
)
|
||||
]
|
||||
|
||||
class PriceCalculationResult:
|
||||
"""Represents the result of a price calculation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
product: Product,
|
||||
calculated_price: Optional[Decimal],
|
||||
base_prices: Dict[str, Decimal],
|
||||
error: Optional[str] = None
|
||||
):
|
||||
self.product = product
|
||||
self.calculated_price = calculated_price
|
||||
self.base_prices = base_prices
|
||||
self.error = error
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self.calculated_price is not None and self.error is None
|
||||
|
||||
@property
|
||||
def max_base_price(self) -> Optional[Decimal]:
|
||||
"""Returns the highest base price."""
|
||||
return max(self.base_prices.values()) if self.base_prices else None
|
||||
|
||||
|
||||
class PricingService:
|
||||
CHUNK_SIZE = 5000 # Configurable batch size
|
||||
MAX_WORKERS = 4 # Configurable worker count
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.df_util = DataframeUtil()
|
||||
self.config = PricingConfiguration
|
||||
self.price_ranges = self.config.get_price_ranges()
|
||||
|
||||
def get_product_by_id(self, product_id: str) -> Optional[Product]:
|
||||
"""Get a product by its ID."""
|
||||
return self.db.query(Product)\
|
||||
.filter(Product.id == str(product_id))\
|
||||
.all()[0] if len(self.db.query(Product)\
|
||||
.filter(Product.id == str(product_id))\
|
||||
.all()) > 0 else None
|
||||
|
||||
def get_latest_price_for_product(self, product: Product, price_type: PriceType) -> Optional[Price]:
|
||||
"""Get the most recent price of a specific type for a product."""
|
||||
prices = self.db.query(Price)\
|
||||
.filter(
|
||||
Price.product_id == str(product.id),
|
||||
Price.type == price_type.value
|
||||
)\
|
||||
.order_by(Price.date_created.desc())\
|
||||
.all()
|
||||
return prices[0] if prices else None
|
||||
|
||||
def get_historical_prices_for_product(
|
||||
self, product: Product, price_type: Optional[PriceType] = None
|
||||
) -> dict[PriceType, list[Price]]:
|
||||
"""Get historical prices for a product, optionally filtered by type."""
|
||||
query = self.db.query(Price).filter(Price.product_id == str(product.id))
|
||||
|
||||
if price_type:
|
||||
query = query.filter(Price.type == price_type.value) # Fixed: Use enum value
|
||||
|
||||
prices = query.order_by(Price.date_created.desc()).all()
|
||||
|
||||
if price_type:
|
||||
return {price_type: prices}
|
||||
|
||||
# Group prices by type
|
||||
result = {t: [] for t in PriceType}
|
||||
for price in prices:
|
||||
result[PriceType(price.type)].append(price) # Fixed: Convert string to enum
|
||||
return result
|
||||
|
||||
def _validate_price_data(self, prices: dict[str, Optional[Price]]) -> Optional[str]:
|
||||
"""Validate price data and return error message if invalid."""
|
||||
# Filter out None values and get valid prices
|
||||
valid_prices = {k: v for k, v in prices.items() if v is not None}
|
||||
|
||||
if not valid_prices:
|
||||
return "No valid price data available"
|
||||
|
||||
for price in valid_prices.values():
|
||||
if price.price < 0:
|
||||
return f"Negative price found: {price.price}"
|
||||
if price.price > self.config.MAX_PRICE:
|
||||
return f"Price exceeds maximum allowed: {price.price}"
|
||||
|
||||
return None
|
||||
|
||||
def _check_price_variance(self, prices: Dict[str, Decimal]) -> bool:
|
||||
"""Check if the variance between prices is within acceptable limits."""
|
||||
if not prices:
|
||||
return True
|
||||
|
||||
min_price = min(prices.values())
|
||||
max_price = max(prices.values())
|
||||
|
||||
if min_price == 0:
|
||||
return False
|
||||
|
||||
variance_ratio = max_price / min_price
|
||||
return variance_ratio <= (1 + self.config.MAX_PRICE_VARIANCE)
|
||||
|
||||
def _get_relevant_prices(self, product: Product) -> dict[str, Optional[Price]]:
|
||||
"""Get all relevant prices for a product."""
|
||||
return {
|
||||
PriceType.TCG_LOW.value: self.get_latest_price_for_product(product, PriceType.TCG_LOW),
|
||||
PriceType.TCG_DIRECT_LOW.value: self.get_latest_price_for_product(product, PriceType.TCG_DIRECT_LOW),
|
||||
PriceType.TCG_MARKET.value: self.get_latest_price_for_product(product, PriceType.TCG_MARKET),
|
||||
PriceType.TCG_LOW_WITH_SHIPPING.value: self.get_latest_price_for_product(product, PriceType.TCG_LOW_WITH_SHIPPING)
|
||||
}
|
||||
|
||||
def _get_base_prices(
|
||||
self, prices: dict[str, Price], include_shipping: bool = False
|
||||
) -> Dict[str, Decimal]:
|
||||
"""Get base prices, excluding None values."""
|
||||
base_prices = {}
|
||||
|
||||
# Add core prices if they exist
|
||||
if tcg_low := prices.get(PriceType.TCG_LOW.value):
|
||||
base_prices[PriceType.TCG_LOW.value] = Decimal(str(tcg_low.price))
|
||||
if tcg_direct := prices.get(PriceType.TCG_DIRECT_LOW.value):
|
||||
base_prices[PriceType.TCG_DIRECT_LOW.value] = Decimal(str(tcg_direct.price))
|
||||
if tcg_market := prices.get(PriceType.TCG_MARKET.value):
|
||||
base_prices[PriceType.TCG_MARKET.value] = Decimal(str(tcg_market.price))
|
||||
|
||||
# Add shipping price if requested and available
|
||||
if include_shipping:
|
||||
if tcg_shipping := prices.get(PriceType.TCG_LOW_WITH_SHIPPING.value):
|
||||
base_prices[PriceType.TCG_LOW_WITH_SHIPPING.value] = Decimal(str(tcg_shipping.price))
|
||||
|
||||
return base_prices
|
||||
|
||||
def _get_price_range(self, price: Decimal) -> Optional[PriceRange]:
|
||||
"""Get the appropriate price range for a given price."""
|
||||
for price_range in self.price_ranges:
|
||||
if price_range.contains_price(price):
|
||||
return price_range
|
||||
return None
|
||||
|
||||
def _handle_floor_price_cases(
|
||||
self, base_prices: Dict[str, Decimal]
|
||||
) -> Optional[Decimal]:
|
||||
"""Handle special cases for prices near or below floor price."""
|
||||
if all(price < self.config.FLOOR_PRICE for price in base_prices.values()):
|
||||
return self.config.FLOOR_PRICE
|
||||
|
||||
if any(price < self.config.FLOOR_PRICE for price in base_prices.values()):
|
||||
max_price = max(base_prices.values())
|
||||
return max_price * self.config.NEAR_FLOOR_MULT
|
||||
|
||||
return None
|
||||
|
||||
def calculate_price(
|
||||
self, product_id: str, strategy: PricingStrategy = PricingStrategy.DEFAULT
|
||||
) -> PriceCalculationResult:
|
||||
"""Calculate the final price for a product using the specified pricing strategy."""
|
||||
# get product
|
||||
product = self.get_product_by_id(str(product_id)) # Fixed: Ensure string UUID
|
||||
if not product:
|
||||
logger.error(f"Product not found: {product_id}")
|
||||
return PriceCalculationResult(product, None, {}, "Product not found")
|
||||
|
||||
# Get all relevant prices
|
||||
prices = self._get_relevant_prices(product)
|
||||
|
||||
# Validate price data
|
||||
if error := self._validate_price_data(prices):
|
||||
logger.error(f"Invalid price data: {error}")
|
||||
logger.error(f"product: {product.id}")
|
||||
return PriceCalculationResult(product, None, {}, error)
|
||||
|
||||
# Get initial base prices without shipping
|
||||
base_prices = self._get_base_prices(prices, include_shipping=False)
|
||||
|
||||
# Check price variance
|
||||
if not self._check_price_variance(base_prices):
|
||||
logger.error(f"Price variance exceeds acceptable threshold")
|
||||
logger.error(f"Base prices: {base_prices}")
|
||||
logger.error(f"product: {product.id}")
|
||||
return PriceCalculationResult(
|
||||
product, None, base_prices,
|
||||
"Price variance exceeds acceptable threshold"
|
||||
)
|
||||
|
||||
# Handle floor price cases
|
||||
if floor_price := self._handle_floor_price_cases(base_prices):
|
||||
return PriceCalculationResult(product, floor_price, base_prices)
|
||||
|
||||
# Get max base price and its range
|
||||
max_base_price = max(base_prices.values())
|
||||
price_range = self._get_price_range(max_base_price)
|
||||
|
||||
if not price_range:
|
||||
logger.error(f"No valid price range found for price")
|
||||
logger.error(f"Base prices: {base_prices}, max_base_price: {max_base_price}")
|
||||
logger.error(f"product: {product.id}")
|
||||
return PriceCalculationResult(
|
||||
product, None, base_prices,
|
||||
f"No valid price range found for price: {max_base_price}"
|
||||
)
|
||||
|
||||
# Include shipping prices if necessary
|
||||
if price_range.include_shipping:
|
||||
base_prices = self._get_base_prices(prices, include_shipping=True)
|
||||
max_base_price = max(base_prices.values())
|
||||
|
||||
# Recheck price range with shipping
|
||||
price_range = self._get_price_range(max_base_price)
|
||||
|
||||
if not price_range:
|
||||
logger.error(f"No valid price range found for price with shipping")
|
||||
logger.error(f"Base prices: {base_prices}, max_base_price: {max_base_price}")
|
||||
logger.error(f"product: {product.id}")
|
||||
return PriceCalculationResult(
|
||||
product, None, base_prices,
|
||||
f"No valid price range found for price with shipping: {max_base_price}"
|
||||
)
|
||||
|
||||
# Calculate final price using the price range
|
||||
calculated_price = price_range.calculate_price(max_base_price)
|
||||
|
||||
# Apply strategy-specific adjustments
|
||||
if strategy == PricingStrategy.AGGRESSIVE:
|
||||
calculated_price *= Decimal('0.95')
|
||||
elif strategy == PricingStrategy.CONSERVATIVE:
|
||||
calculated_price *= Decimal('1.05')
|
||||
|
||||
debug_base_prices_with_name_string = ", ".join([f"{k}: {v}" for k, v in base_prices.items()])
|
||||
|
||||
logger.debug(f"Set price for to {calculated_price.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)} based on {debug_base_prices_with_name_string}")
|
||||
|
||||
return PriceCalculationResult(
|
||||
product,
|
||||
calculated_price.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP),
|
||||
base_prices
|
||||
)
|
||||
|
||||
def _bulk_generate_uuids(self, size: int) -> List[str]:
|
||||
"""Generate UUIDs in bulk for better performance."""
|
||||
return [str(uuid()) for _ in range(size)]
|
||||
|
||||
def _prepare_price_records(self, df: pd.DataFrame, price_type: str, uuids: List[str]) -> List[Dict]:
|
||||
"""Prepare price records in bulk using vectorized operations."""
|
||||
records = []
|
||||
df['price_id'] = uuids[:len(df)]
|
||||
df['type'] = price_type # price_type should already be a string value
|
||||
df['date_created'] = datetime.utcnow()
|
||||
|
||||
return df[['price_id', 'product_id', 'type', 'price', 'date_created']].to_dict('records')
|
||||
|
||||
def _calculate_suggested_prices_batch(self, product_ids: List[str]) -> Dict[str, float]:
|
||||
"""Calculate suggested prices in parallel for a batch of products."""
|
||||
with ThreadPoolExecutor(max_workers=self.MAX_WORKERS) as executor:
|
||||
future_to_id = {
|
||||
executor.submit(self.calculate_price, str(pid)): pid # Fixed: Ensure string UUID
|
||||
for pid in product_ids
|
||||
}
|
||||
|
||||
results = {}
|
||||
for future in as_completed(future_to_id):
|
||||
product_id = future_to_id[future]
|
||||
try:
|
||||
result = future.result()
|
||||
if result.success:
|
||||
results[str(product_id)] = float(result.calculated_price) # Fixed: Ensure string UUID
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to calculate price for product {product_id}: {e}")
|
||||
|
||||
return results
|
||||
|
||||
def _bulk_insert_prices(self, records: List[Dict]) -> None:
|
||||
"""Efficiently insert price records in bulk."""
|
||||
if not records:
|
||||
return
|
||||
|
||||
try:
|
||||
df = pd.DataFrame(records)
|
||||
df.to_sql('prices', self.db.bind,
|
||||
if_exists='append',
|
||||
index=False,
|
||||
method='multi',
|
||||
chunksize=self.CHUNK_SIZE)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to bulk insert prices: {e}")
|
||||
raise
|
||||
|
||||
def process_pricing_export(self, export_csv: bytes) -> None:
|
||||
"""Process pricing export with optimized bulk operations."""
|
||||
try:
|
||||
# Convert CSV to DataFrame
|
||||
df = self.df_util.csv_bytes_to_df(export_csv)
|
||||
df.columns = df.columns.str.lower().str.replace(' ', '_')
|
||||
|
||||
# Get product mappings efficiently - SQLite compatible with chunking
|
||||
SQLITE_MAX_VARS = 999 # SQLite parameter limit
|
||||
tcgplayer_ids = df['tcgplayer_id'].tolist()
|
||||
all_product_dfs = []
|
||||
|
||||
for i in range(0, len(tcgplayer_ids), SQLITE_MAX_VARS):
|
||||
chunk_ids = tcgplayer_ids[i:i + SQLITE_MAX_VARS]
|
||||
placeholders = ','.join([':id_' + str(j) for j in range(len(chunk_ids))])
|
||||
product_query = f"""
|
||||
SELECT tcgplayer_id, product_id
|
||||
FROM card_tcgplayer
|
||||
WHERE tcgplayer_id IN ({placeholders})
|
||||
"""
|
||||
|
||||
# Create a dictionary of parameters
|
||||
params = {f'id_{j}': id_val for j, id_val in enumerate(chunk_ids)}
|
||||
|
||||
chunk_df = pd.read_sql(
|
||||
text(product_query),
|
||||
self.db.bind,
|
||||
params=params
|
||||
)
|
||||
all_product_dfs.append(chunk_df)
|
||||
|
||||
# Combine all chunks
|
||||
product_df = pd.concat(all_product_dfs) if all_product_dfs else pd.DataFrame()
|
||||
|
||||
# Merge dataframes efficiently
|
||||
merged_df = pd.merge(
|
||||
df,
|
||||
product_df,
|
||||
on='tcgplayer_id',
|
||||
how='inner'
|
||||
)
|
||||
|
||||
# Define price columns mapping - using enum values directly
|
||||
price_columns = {
|
||||
'tcg_market_price': PriceType.TCG_MARKET.value,
|
||||
'tcg_direct_low': PriceType.TCG_DIRECT_LOW.value,
|
||||
'tcg_low_price_with_shipping': PriceType.TCG_LOW_WITH_SHIPPING.value,
|
||||
'tcg_low_price': PriceType.TCG_LOW.value,
|
||||
'tcg_marketplace_price': PriceType.TCG_MARKETPLACE.value
|
||||
}
|
||||
|
||||
# Process each price type in chunks
|
||||
for price_col, price_type in price_columns.items():
|
||||
valid_prices_df = merged_df[merged_df[price_col].notna()].copy()
|
||||
|
||||
for chunk_start in range(0, len(valid_prices_df), self.CHUNK_SIZE):
|
||||
chunk_df = valid_prices_df.iloc[chunk_start:chunk_start + self.CHUNK_SIZE].copy()
|
||||
uuids = self._bulk_generate_uuids(len(chunk_df))
|
||||
|
||||
chunk_df['price'] = chunk_df[price_col]
|
||||
chunk_df['product_id'] = chunk_df['product_id'].astype(str) # Fixed: Ensure string UUIDs
|
||||
records = self._prepare_price_records(chunk_df, price_type, uuids)
|
||||
self._bulk_insert_prices(records)
|
||||
|
||||
# Handle suggested prices separately with parallel processing
|
||||
product_ids = merged_df['product_id'].unique()
|
||||
suggested_prices = {}
|
||||
|
||||
for chunk_start in range(0, len(product_ids), self.CHUNK_SIZE):
|
||||
chunk_ids = product_ids[chunk_start:chunk_start + self.CHUNK_SIZE]
|
||||
chunk_prices = self._calculate_suggested_prices_batch(chunk_ids)
|
||||
suggested_prices.update(chunk_prices)
|
||||
|
||||
# Create suggested price records
|
||||
if suggested_prices:
|
||||
suggested_df = pd.DataFrame([
|
||||
{'product_id': str(pid), 'price': price} # Fixed: Ensure string UUIDs
|
||||
for pid, price in suggested_prices.items()
|
||||
])
|
||||
|
||||
uuids = self._bulk_generate_uuids(len(suggested_df))
|
||||
records = self._prepare_price_records(suggested_df, 'suggested_price', uuids)
|
||||
self._bulk_insert_prices(records)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process pricing export: {e}")
|
||||
logger.error(f"Error occurred during price processing: {str(e)}")
|
||||
raise
|
72
app/services/util/_dataframe.py
Normal file
72
app/services/util/_dataframe.py
Normal file
@ -0,0 +1,72 @@
|
||||
import pandas as pd
|
||||
from io import StringIO
|
||||
from app.db.models import File
|
||||
|
||||
|
||||
class ManaboxRow:
|
||||
def __init__(self, row: pd.Series):
|
||||
# Integer field
|
||||
try:
|
||||
self.manabox_id = int(row['manabox_id'])
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError(f"manabox_id must be convertible to integer, got: {row['manabox_id']}")
|
||||
|
||||
# String fields with None/NaN handling
|
||||
self.name = str(row['name']) if pd.notna(row['name']) else ''
|
||||
self.set_code = str(row['set_code']) if pd.notna(row['set_code']) else ''
|
||||
self.set_name = str(row['set_name']) if pd.notna(row['set_name']) else ''
|
||||
self.collector_number = str(row['collector_number']) if pd.notna(row['collector_number']) else ''
|
||||
self.foil = str(row['foil']) if pd.notna(row['foil']) else ''
|
||||
self.rarity = str(row['rarity']) if pd.notna(row['rarity']) else ''
|
||||
self.scryfall_id = str(row['scryfall_id']) if pd.notna(row['scryfall_id']) else ''
|
||||
self.condition = str(row['condition']) if pd.notna(row['condition']) else ''
|
||||
self.language = str(row['language']) if pd.notna(row['language']) else ''
|
||||
self.quantity = str(row['quantity']) if pd.notna(row['quantity']) else ''
|
||||
|
||||
|
||||
class TCGPlayerPricingRow:
|
||||
def __init__(self, row: pd.Series):
|
||||
self.tcgplayer_id = row['tcgplayer_id']
|
||||
self.product_line = row['product_line']
|
||||
self.set_name = row['set_name']
|
||||
self.product_name = row['product_name']
|
||||
self.title = row['title']
|
||||
self.number = row['number']
|
||||
self.rarity = row['rarity']
|
||||
self.condition = row['condition']
|
||||
self.tcg_market_price = row['tcg_market_price']
|
||||
self.tcg_direct_low = row['tcg_direct_low']
|
||||
self.tcg_low_price_with_shipping = row['tcg_low_price_with_shipping']
|
||||
self.tcg_low_price = row['tcg_low_price']
|
||||
self.total_quantity = row['total_quantity']
|
||||
self.add_to_quantity = row['add_to_quantity']
|
||||
self.tcg_marketplace_price = row['tcg_marketplace_price']
|
||||
self.photo_url = row['photo_url']
|
||||
|
||||
|
||||
class DataframeUtil:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def format_df_columns(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
df.columns = df.columns.str.lower()
|
||||
df.columns = df.columns.str.replace(' ', '_')
|
||||
return df
|
||||
|
||||
def file_to_df(self, file: File) -> pd.DataFrame:
|
||||
with open(file.filepath, 'rb') as f:
|
||||
content = f.read()
|
||||
content = content.decode('utf-8')
|
||||
df = pd.read_csv(StringIO(content))
|
||||
df = self.format_df_columns(df)
|
||||
return df
|
||||
|
||||
def csv_bytes_to_df(self, content: bytes) -> pd.DataFrame:
|
||||
content = content.decode('utf-8')
|
||||
df = pd.read_csv(StringIO(content))
|
||||
df = self.format_df_columns(df)
|
||||
return df
|
||||
|
||||
def df_to_csv_bytes(self, df: pd.DataFrame) -> bytes:
|
||||
csv = df.to_csv(index=False)
|
||||
return csv.encode('utf-8')
|
49
app/services/util/_docker.py
Normal file
49
app/services/util/_docker.py
Normal file
@ -0,0 +1,49 @@
|
||||
import os
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DockerUtil:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def is_in_docker(self) -> bool:
|
||||
"""Check if we're running inside a Docker container using multiple methods"""
|
||||
# Method 1: Check cgroup
|
||||
try:
|
||||
with open('/proc/1/cgroup', 'r') as f:
|
||||
content = f.read().lower()
|
||||
if any(container_id in content for container_id in ['docker', 'containerd', 'kubepods']):
|
||||
logger.debug("Docker detected via cgroup")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read cgroup file: {e}")
|
||||
|
||||
# Method 2: Check /.dockerenv file
|
||||
if os.path.exists('/.dockerenv'):
|
||||
logger.debug("Docker detected via /.dockerenv file")
|
||||
return True
|
||||
|
||||
# Method 3: Check environment variables
|
||||
docker_env = any(os.environ.get(var, False) for var in [
|
||||
'DOCKER_CONTAINER',
|
||||
'IN_DOCKER',
|
||||
'KUBERNETES_SERVICE_HOST', # For k8s
|
||||
'DOCKER_HOST'
|
||||
])
|
||||
if docker_env:
|
||||
logger.debug("Docker detected via environment variables")
|
||||
return True
|
||||
|
||||
# Method 4: Check container runtime
|
||||
try:
|
||||
with open('/proc/self/mountinfo', 'r') as f:
|
||||
content = f.read().lower()
|
||||
if any(rt in content for rt in ['docker', 'containerd', 'kubernetes']):
|
||||
logger.debug("Docker detected via mountinfo")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read mountinfo: {e}")
|
||||
|
||||
logger.debug("No Docker environment detected")
|
||||
return False
|
149
app/services/util/_requests.py
Normal file
149
app/services/util/_requests.py
Normal file
@ -0,0 +1,149 @@
|
||||
from typing import Dict, Optional
|
||||
from app.services.util._docker import DockerUtil
|
||||
from enum import Enum
|
||||
import browser_cookie3
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
import time
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Browser(Enum):
|
||||
"""Supported browser types for cookie extraction"""
|
||||
BRAVE = "brave"
|
||||
CHROME = "chrome"
|
||||
FIREFOX = "firefox"
|
||||
|
||||
class Method(Enum):
|
||||
"""Supported HTTP methods"""
|
||||
GET = "GET"
|
||||
POST = "POST"
|
||||
|
||||
class TCGPlayerEndpoints(Enum):
|
||||
"""Supported TCGPlayer API endpoints"""
|
||||
ORDERS = "https://order-management-api.tcgplayer.com/orders"
|
||||
|
||||
class Headers:
|
||||
ACCEPT = 'application/json, text/plain, */*'
|
||||
ACCEPT_ENCODING = 'gzip, deflate, br, zstd'
|
||||
ACCEPT_LANGUAGE = 'en-US,en;q=0.8'
|
||||
PRIORITY = 'u=1, i'
|
||||
SECCHUA = '"Not(A:Brand";v="99", "Brave";v="133", "Chromium";v="133"'
|
||||
SECCHUA_MOBILE = '?0'
|
||||
SECCHUA_PLATFORM = '"macOS"'
|
||||
SEC_FETCH_DEST = 'empty'
|
||||
SEC_FETCH_MODE = 'cors'
|
||||
SEC_FETCH_SITE = 'same-site'
|
||||
SEC_GPC = '1'
|
||||
USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36'
|
||||
SELLER_ORIGIN = 'https://sellerportal.tcgplayer.com'
|
||||
SELLER_REFERER = 'https://sellerportal.tcgplayer.com/'
|
||||
|
||||
class RequestHeaders:
|
||||
BASE_HEADERS = {
|
||||
'accept': Headers.ACCEPT,
|
||||
'accept-encoding': Headers.ACCEPT_ENCODING,
|
||||
'accept-language': Headers.ACCEPT_LANGUAGE,
|
||||
'priority': Headers.PRIORITY,
|
||||
'sec-ch-ua': Headers.SECCHUA,
|
||||
'sec-ch-ua-mobile': Headers.SECCHUA_MOBILE,
|
||||
'sec-ch-ua-platform': Headers.SECCHUA_PLATFORM,
|
||||
'sec-fetch-dest': Headers.SEC_FETCH_DEST,
|
||||
'sec-fetch-mode': Headers.SEC_FETCH_MODE,
|
||||
'sec-fetch-site': Headers.SEC_FETCH_SITE,
|
||||
'sec-gpc': Headers.SEC_GPC,
|
||||
'user-agent': Headers.USER_AGENT
|
||||
}
|
||||
SELLER_HEADERS = {
|
||||
'origin': Headers.SELLER_ORIGIN,
|
||||
'referer': Headers.SELLER_REFERER
|
||||
}
|
||||
POST_HEADERS = {
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
|
||||
class URLHeaders:
|
||||
# combine base and seller headers
|
||||
ORDER_HEADERS = {**RequestHeaders.BASE_HEADERS, **RequestHeaders.SELLER_HEADERS}
|
||||
POST_HEADERS = {**RequestHeaders.BASE_HEADERS, **RequestHeaders.SELLER_HEADERS, **RequestHeaders.POST_HEADERS}
|
||||
|
||||
class RequestsUtil:
|
||||
def __init__(self, browser_type: Browser = Browser.BRAVE):
|
||||
self.browser_type = browser_type
|
||||
self.docker_util = DockerUtil()
|
||||
self.previous_request_time = datetime.now()
|
||||
|
||||
def get_session(self, cookies: Dict = None) -> requests.Session:
|
||||
"""Create a session with the specified cookies"""
|
||||
session = requests.Session()
|
||||
if cookies:
|
||||
session.cookies.update(cookies)
|
||||
return session
|
||||
|
||||
def bare_request(self, url: str, method: str, cookies: dict = None, data=None, files=None) -> requests.Response:
|
||||
"""Send a request without any additional processing"""
|
||||
try:
|
||||
response = requests.request(method, url, cookies=cookies, data=data, files=files)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Request failed: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_tcgplayer_cookies_from_file(self) -> Dict:
|
||||
# check if cookies file exists
|
||||
if not os.path.exists('cookies/tcg_cookies.json'):
|
||||
raise ValueError("Cookies file not found")
|
||||
with open('cookies/tcg_cookies.json', 'r') as f:
|
||||
logger.debug("Loading cookies from file")
|
||||
cookies = json.load(f)
|
||||
return cookies
|
||||
|
||||
def get_tcgplayer_browser_cookies(self) -> Optional[Dict]:
|
||||
"""Retrieve cookies from the specified browser"""
|
||||
try:
|
||||
cookie_getter = getattr(browser_cookie3, self.browser_type.value, None)
|
||||
if not cookie_getter:
|
||||
raise ValueError(f"Unsupported browser type: {self.browser_type.value}")
|
||||
return cookie_getter()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get browser cookies: {str(e)}")
|
||||
return None
|
||||
|
||||
def rate_limit(self, time_between_requests: int = 10):
|
||||
"""Rate limit requests by waiting for a specified time between requests"""
|
||||
time_diff = (datetime.now() - self.previous_request_time).total_seconds()
|
||||
if time_diff < time_between_requests:
|
||||
time.sleep(time_between_requests - time_diff)
|
||||
|
||||
def send_request(self, url: str, method: str, cookies: dict, data=None, json=None) -> requests.Response:
|
||||
"""Send a request with the specified cookies"""
|
||||
|
||||
headers = self.set_headers(url, method)
|
||||
if not headers:
|
||||
raise ValueError("Headers not set")
|
||||
|
||||
try:
|
||||
self.rate_limit()
|
||||
response = requests.request(method, url, headers=headers, cookies=cookies, data=data, json=json)
|
||||
response.raise_for_status()
|
||||
self.previous_request_time = datetime.now()
|
||||
|
||||
return response
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Request failed: {str(e)}")
|
||||
return None
|
||||
|
||||
def set_headers(self, url: str, method: str) -> Dict:
|
||||
# use tcgplayerendpoints enum to set headers where url partially matches enum value
|
||||
for endpoint in TCGPlayerEndpoints:
|
||||
if endpoint.value in url and str.upper(method) == "POST":
|
||||
return URLHeaders.POST_HEADERS
|
||||
elif endpoint.value in url:
|
||||
return URLHeaders.ORDER_HEADERS
|
||||
else:
|
||||
raise ValueError(f"Endpoint not found in TCGPlayerEndpoints: {url}")
|
212
app/tests/box_test.py
Normal file
212
app/tests/box_test.py
Normal file
@ -0,0 +1,212 @@
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi import BackgroundTasks
|
||||
import pytest
|
||||
import os
|
||||
from app.main import app
|
||||
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
test_boxes = []
|
||||
|
||||
def test_create_box():
|
||||
# Send as form data, not JSON
|
||||
response = client.post("/api/boxes",
|
||||
data={
|
||||
"type": "play",
|
||||
"set_code": "BLB",
|
||||
"sku": "1234",
|
||||
"num_cards_expected": 504
|
||||
}
|
||||
)
|
||||
test_boxes.append(response.json()["box"][0]["product_id"])
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["success"] == True
|
||||
assert response.json()["box"][0]["type"] == "play"
|
||||
assert response.json()["box"][0]["set_code"] == "BLB"
|
||||
assert response.json()["box"][0]["sku"] == "1234"
|
||||
assert response.json()["box"][0]["num_cards_expected"] == 504
|
||||
|
||||
def test_update_box():
|
||||
# Create a box first
|
||||
create_response = client.post("/api/boxes",
|
||||
data={
|
||||
"type": "collector",
|
||||
"set_code": "MKM",
|
||||
"sku": "3456",
|
||||
"num_cards_expected": 504
|
||||
}
|
||||
)
|
||||
box_id = create_response.json()["box"][0]["product_id"]
|
||||
test_boxes.append(box_id)
|
||||
|
||||
# Update the box
|
||||
response = client.put(f"/api/boxes/{box_id}",
|
||||
data={
|
||||
"num_cards_expected": 500
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] == True
|
||||
assert response.json()["box"][0]["type"] == "collector"
|
||||
assert response.json()["box"][0]["set_code"] == "MKM"
|
||||
assert response.json()["box"][0]["sku"] == "3456"
|
||||
assert response.json()["box"][0]["num_cards_expected"] == 500
|
||||
|
||||
def test_delete_box():
|
||||
# Create a box first
|
||||
create_response = client.post("/api/boxes",
|
||||
data={
|
||||
"type": "set",
|
||||
"set_code": "LCI",
|
||||
"sku": "7890",
|
||||
"num_cards_expected": 504
|
||||
}
|
||||
)
|
||||
box_id = create_response.json()["box"][0]["product_id"]
|
||||
|
||||
# Delete the box
|
||||
response = client.delete(f"/api/boxes/{box_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] == True
|
||||
assert response.json()["box"][0]["type"] == "set"
|
||||
assert response.json()["box"][0]["set_code"] == "LCI"
|
||||
assert response.json()["box"][0]["sku"] == "7890"
|
||||
assert response.json()["box"][0]["num_cards_expected"] == 504
|
||||
|
||||
# Constants for reused values
|
||||
TEST_FILE_PATH = os.path.join(os.getcwd(), "tests/test_files", "manabox_test_file.csv")
|
||||
DEFAULT_METADATA = {
|
||||
"source": "manabox",
|
||||
"type": "scan_export_common"
|
||||
}
|
||||
|
||||
def get_file_size_kb(file_path):
|
||||
"""Helper to consistently calculate file size in KB"""
|
||||
with open(file_path, "rb") as f:
|
||||
return round(len(f.read()) / 1024, 2)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_box():
|
||||
"""Test creating a new manabox file"""
|
||||
# Open file within the test scope
|
||||
with open(TEST_FILE_PATH, "rb") as test_file:
|
||||
files = {"file": test_file}
|
||||
|
||||
# Make request
|
||||
response = client.post("/api/files", data=DEFAULT_METADATA, files=files)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 201
|
||||
assert response.json()["success"] == True
|
||||
|
||||
file_data = response.json()["files"][0]
|
||||
assert file_data["source"] == DEFAULT_METADATA["source"]
|
||||
assert file_data["type"] == DEFAULT_METADATA["type"]
|
||||
assert file_data["status"] == "pending"
|
||||
assert file_data["service"] == None
|
||||
assert file_data["filename"] == "manabox_test_file.csv"
|
||||
assert file_data["filesize_kb"] == get_file_size_kb(TEST_FILE_PATH)
|
||||
assert file_data["id"] is not None
|
||||
|
||||
# Execute background tasks if they were added
|
||||
background_tasks = BackgroundTasks()
|
||||
for task in background_tasks.tasks:
|
||||
await task()
|
||||
|
||||
# Create a box first
|
||||
create_response = client.post("/api/boxes",
|
||||
data={
|
||||
"type": "play",
|
||||
"set_code": "OTJ",
|
||||
"sku": "2314",
|
||||
"num_cards_expected": 504
|
||||
}
|
||||
)
|
||||
box_id = create_response.json()["box"][0]["product_id"]
|
||||
test_boxes.append(box_id)
|
||||
|
||||
# Open the box
|
||||
response = client.post(f"/api/boxes/{box_id}/open",
|
||||
data={
|
||||
"product_id": box_id,
|
||||
"file_ids": [file_data["id"]],
|
||||
"num_cards_actual": 500
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["success"] == True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_open_box():
|
||||
with open(TEST_FILE_PATH, "rb") as test_file:
|
||||
files = {"file": test_file}
|
||||
|
||||
# Make request
|
||||
response = client.post("/api/files", data=DEFAULT_METADATA, files=files)
|
||||
file_id = response.json()["files"][0]["id"]
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 201
|
||||
assert response.json()["success"] == True
|
||||
|
||||
file_data = response.json()["files"][0]
|
||||
assert file_data["source"] == DEFAULT_METADATA["source"]
|
||||
assert file_data["type"] == DEFAULT_METADATA["type"]
|
||||
assert file_data["status"] == "pending"
|
||||
assert file_data["service"] == None
|
||||
assert file_data["filename"] == "manabox_test_file.csv"
|
||||
assert file_data["filesize_kb"] == get_file_size_kb(TEST_FILE_PATH)
|
||||
assert file_data["id"] is not None
|
||||
|
||||
# Execute background tasks if they were added
|
||||
background_tasks = BackgroundTasks()
|
||||
for task in background_tasks.tasks:
|
||||
await task()
|
||||
|
||||
# Create a box first
|
||||
create_response = client.post("/api/boxes",
|
||||
data={
|
||||
"type": "play",
|
||||
"set_code": "INR",
|
||||
"sku": "1423",
|
||||
"num_cards_expected": 504
|
||||
}
|
||||
)
|
||||
box_id = create_response.json()["box"][0]["product_id"]
|
||||
|
||||
# Open the box
|
||||
open_response = client.post(f"/api/boxes/{box_id}/open",
|
||||
data={
|
||||
"product_id": box_id,
|
||||
"file_ids": [file_id],
|
||||
"num_cards_actual": 500
|
||||
}
|
||||
)
|
||||
|
||||
# Check if the box is opened
|
||||
assert open_response.status_code == 201
|
||||
assert open_response.json()["success"] == True
|
||||
|
||||
# Get the open box ID
|
||||
open_box_id = open_response.json()["open_box"][0]["id"]
|
||||
|
||||
# Delete the open box
|
||||
response = client.delete(f"/api/boxes/{open_box_id}/open")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] == True
|
||||
|
||||
|
||||
def test_cleanup():
|
||||
cleanup = True
|
||||
# Delete all boxes created during testing
|
||||
if cleanup:
|
||||
for box_id in test_boxes:
|
||||
client.delete(f"/api/boxes/{box_id}")
|
||||
|
123
app/tests/file_test.py
Normal file
123
app/tests/file_test.py
Normal file
@ -0,0 +1,123 @@
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi import BackgroundTasks
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
import asyncio
|
||||
import os
|
||||
from app.main import app
|
||||
from app.services.file import FileService
|
||||
from app.services.task import TaskService
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
# Constants for reused values
|
||||
TEST_FILE_PATH = os.path.join(os.getcwd(), "tests/test_files", "manabox_test_file.csv")
|
||||
DEFAULT_METADATA = {
|
||||
"source": "manabox",
|
||||
"type": "scan_export_rare"
|
||||
}
|
||||
|
||||
def get_file_size_kb(file_path):
|
||||
"""Helper to consistently calculate file size in KB"""
|
||||
with open(file_path, "rb") as f:
|
||||
return round(len(f.read()) / 1024, 2)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_manabox_file():
|
||||
"""Test creating a new manabox file"""
|
||||
# Open file within the test scope
|
||||
with open(TEST_FILE_PATH, "rb") as test_file:
|
||||
files = {"file": test_file}
|
||||
|
||||
# Make request
|
||||
response = client.post("/api/files", data=DEFAULT_METADATA, files=files)
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 201
|
||||
assert response.json()["success"] == True
|
||||
|
||||
file_data = response.json()["files"][0]
|
||||
assert file_data["source"] == DEFAULT_METADATA["source"]
|
||||
assert file_data["type"] == DEFAULT_METADATA["type"]
|
||||
assert file_data["status"] == "pending"
|
||||
assert file_data["service"] == None
|
||||
assert file_data["filename"] == "manabox_test_file.csv"
|
||||
assert file_data["filesize_kb"] == get_file_size_kb(TEST_FILE_PATH)
|
||||
assert file_data["id"] is not None
|
||||
|
||||
# Execute background tasks if they were added
|
||||
background_tasks = BackgroundTasks()
|
||||
for task in background_tasks.tasks:
|
||||
await task()
|
||||
|
||||
def test_get_file():
|
||||
"""Test retrieving a specific file"""
|
||||
# Create a file first
|
||||
with open(TEST_FILE_PATH, "rb") as test_file:
|
||||
files = {"file": test_file}
|
||||
create_response = client.post("/api/files", data=DEFAULT_METADATA, files=files)
|
||||
file_id = create_response.json()["files"][0]["id"]
|
||||
|
||||
# Get the file
|
||||
response = client.get(f"/api/files/{file_id}")
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] == True
|
||||
|
||||
file_data = response.json()["files"][0]
|
||||
assert file_data["source"] == DEFAULT_METADATA["source"]
|
||||
assert file_data["type"] == DEFAULT_METADATA["type"]
|
||||
assert file_data["status"] == "completed"
|
||||
assert file_data["service"] == None
|
||||
assert file_data["filename"] == "manabox_test_file.csv"
|
||||
assert file_data["filesize_kb"] == get_file_size_kb(TEST_FILE_PATH)
|
||||
assert file_data["id"] == file_id
|
||||
|
||||
def test_delete_file():
|
||||
"""Test file deletion"""
|
||||
# Create a file first
|
||||
with open(TEST_FILE_PATH, "rb") as test_file:
|
||||
files = {"file": test_file}
|
||||
create_response = client.post("/api/files", data=DEFAULT_METADATA, files=files)
|
||||
file_id = create_response.json()["files"][0]["id"]
|
||||
|
||||
# Delete the file
|
||||
response = client.delete(f"/api/files/{file_id}")
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] == True
|
||||
|
||||
file_data = response.json()["files"][0]
|
||||
assert file_data["source"] == DEFAULT_METADATA["source"]
|
||||
assert file_data["type"] == DEFAULT_METADATA["type"]
|
||||
assert file_data["status"] == "deleted"
|
||||
assert file_data["service"] == None
|
||||
assert file_data["filename"] == "manabox_test_file.csv"
|
||||
assert file_data["filesize_kb"] == get_file_size_kb(TEST_FILE_PATH)
|
||||
assert file_data["id"] == file_id
|
||||
|
||||
def test_get_prepared_files():
|
||||
"""Test retrieving files filtered by status"""
|
||||
# Create a test file first
|
||||
with open(TEST_FILE_PATH, "rb") as test_file:
|
||||
files = {"file": test_file}
|
||||
create_response = client.post("/api/files", data=DEFAULT_METADATA, files=files)
|
||||
file_id = create_response.json()["files"][0]["id"]
|
||||
|
||||
# Get prepared files
|
||||
response = client.get("/api/files?status=completed")
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] == True
|
||||
|
||||
# get file from id
|
||||
file_data = [file for file in response.json()["files"] if file["id"] == file_id][0]
|
||||
assert file_data["source"] == DEFAULT_METADATA["source"]
|
||||
assert file_data["type"] == DEFAULT_METADATA["type"]
|
||||
assert file_data["status"] == "completed"
|
||||
assert file_data["service"] == None
|
||||
assert file_data["filename"] == "manabox_test_file.csv"
|
||||
assert file_data["filesize_kb"] == get_file_size_kb(TEST_FILE_PATH)
|
504
app/tests/test_files/manabox_test_file.csv
Normal file
504
app/tests/test_files/manabox_test_file.csv
Normal file
@ -0,0 +1,504 @@
|
||||
Name,Set code,Set name,Collector number,Foil,Rarity,Quantity,ManaBox ID,Scryfall ID,Purchase price,Misprint,Altered,Condition,Language,Purchase price currency
|
||||
"Tinybones, Bauble Burglar",FDN,Foundations,72,normal,rare,1,101414,ff3d85bc-ef2d-4251-baf4-a14bd0cee61e,0.66,false,false,near_mint,en,USD
|
||||
Scrawling Crawler,FDN,Foundations,132,normal,rare,1,100912,a1176dcf-40ee-4342-aa74-791b8352e99a,4.81,false,false,near_mint,en,USD
|
||||
"Giada, Font of Hope",FDN,Foundations,141,normal,rare,1,100804,8ae6fc26-cfad-4da8-98d9-49c27c24d293,1.33,false,false,near_mint,en,USD
|
||||
Blasphemous Edict,FDN,Foundations,57,normal,rare,1,100168,11040ecd-3153-4029-b42b-1441bc51ec34,6.9,false,false,near_mint,en,USD
|
||||
"Drakuseth, Maw of Flames",FDN,Foundations,193,normal,rare,1,100092,029b1edb-e1de-4f1c-81df-8d17f4920318,0.33,false,false,near_mint,en,USD
|
||||
"Koma, World-Eater",FDN,Foundations,347,normal,rare,1,100792,8889e1ca-eec1-408b-b11e-98cc0a357a97,4.69,false,false,near_mint,en,USD
|
||||
"Ghalta, Primal Hunger",FDN,Foundations,222,normal,rare,1,100635,6a9c39e4-a8cf-42dd-8d0e-45634b335546,0.54,false,false,near_mint,en,USD
|
||||
Sire of Seven Deaths,FDN,Foundations,1,normal,mythic,1,100812,8d8432a7-1c8a-4cfb-947c-ecf9791063eb,18.63,false,false,near_mint,en,USD
|
||||
Hero's Downfall,FDN,Foundations,319,normal,uncommon,1,101639,10cedc6d-075a-4f9b-a858-e2c29809ee33,0.39,false,false,near_mint,en,USD
|
||||
"Etali, Primal Storm",FDN,Foundations,194,normal,rare,1,101037,b6af9894-95b5-4c8e-902f-a9ba70f02e4a,0.32,false,false,near_mint,en,USD
|
||||
High Fae Trickster,FDN,Foundations,307,normal,rare,1,100918,a21180a4-208f-4c13-a704-58403ddaf12f,3.39,false,false,near_mint,en,USD
|
||||
Mocking Sprite,FDN,Foundations,159,foil,common,1,101624,f6792f63-b651-497d-8aa5-cddf4cedeca8,0.09,false,false,near_mint,en,USD
|
||||
Bake into a Pie,FDN,Foundations,169,foil,common,1,101494,2ab0e660-86a3-4b92-82fa-77dcb5db947d,0.06,false,false,near_mint,en,USD
|
||||
Boltwave,FDN,Foundations,79,foil,uncommon,1,100810,8d1ec351-5e70-4eb2-b590-6bff94ef8178,4.27,false,false,near_mint,en,USD
|
||||
Jungle Hollow,FDN,Foundations,263,foil,common,1,101224,dc758e14-d370-45e4-bbc5-938fb4d21127,0.08,false,false,near_mint,en,USD
|
||||
Ambush Wolf,FDN,Foundations,98,foil,common,1,101492,2903832c-318e-42ab-bf58-c682ec2f7afd,0.03,false,false,near_mint,en,USD
|
||||
An Offer You Can't Refuse,FDN,Foundations,160,foil,uncommon,1,100948,a829747f-cf9b-4d81-ba66-9f0630ed4565,1.51,false,false,near_mint,en,USD
|
||||
Sower of Chaos,FDN,Foundations,95,foil,common,1,101556,7ff50606-491c-4946-8d03-719b01cfad77,0.02,false,false,near_mint,en,USD
|
||||
Guarded Heir,FDN,Foundations,14,foil,uncommon,1,100505,525ba5c7-3ce5-4e52-b8b5-96c9040a6738,0.06,false,false,near_mint,en,USD
|
||||
Wind-Scarred Crag,FDN,Foundations,271,foil,common,1,100684,759e99df-11a8-4aee-b6bc-344e84e10d94,0.08,false,false,near_mint,en,USD
|
||||
Think Twice,FDN,Foundations,165,foil,common,1,101202,d88faaa1-eb41-40f7-991c-5c06e1138f3d,0.03,false,false,near_mint,en,USD
|
||||
Grow from the Ashes,FDN,Foundations,225,foil,common,1,101502,42525f8a-aee7-4811-8f05-471b559c2c4a,0.07,false,false,near_mint,en,USD
|
||||
Spitfire Lagac,FDN,Foundations,208,foil,common,1,101496,30f600cd-b696-4f49-9cbc-5a33aa43d04c,0.05,false,false,near_mint,en,USD
|
||||
Abyssal Harvester,FDN,Foundations,54,foil,rare,1,101342,f2e0f538-5825-47e9-883c-3ec6fd5b25ea,3.18,false,false,near_mint,en,USD
|
||||
Sanguine Syphoner,FDN,Foundations,68,foil,common,1,101582,b1daf5bb-c8e9-4e79-a532-ca92a9a885cd,0.19,false,false,near_mint,en,USD
|
||||
Goldvein Pick,FDN,Foundations,253,foil,common,1,101572,a241317d-2277-467e-a8f9-aa71c944e244,0.06,false,false,near_mint,en,USD
|
||||
Goblin Negotiation,FDN,Foundations,88,foil,uncommon,1,101335,f2016585-e26c-4d13-b09f-af6383c192f7,0.14,false,false,near_mint,en,USD
|
||||
Banishing Light,FDN,Foundations,138,foil,common,1,101613,e38dc3b3-1629-491b-8afd-0e7a9a857713,0.05,false,false,near_mint,en,USD
|
||||
Dauntless Veteran,FDN,Foundations,8,foil,uncommon,1,100704,7a136f26-ac66-407f-b389-357222d2c4a2,0.06,false,false,near_mint,en,USD
|
||||
Run Away Together,FDN,Foundations,162,foil,common,1,101614,e598eb7b-10dc-49e6-ac60-2fefa987173e,0.02,false,false,near_mint,en,USD
|
||||
"Tatyova, Benthic Druid",FDN,Foundations,247,foil,uncommon,1,101301,eabc978a-0666-472d-bdc6-d4b29d29eca4,0.14,false,false,near_mint,en,USD
|
||||
"Balmor, Battlemage Captain",FDN,Foundations,237,foil,uncommon,1,100142,0b45ab13-9bb6-48af-8b37-d97b25801ac8,0.13,false,false,near_mint,en,USD
|
||||
Involuntary Employment,FDN,Foundations,203,foil,common,1,101622,f3ad3d62-2f24-4562-b3fa-809213dbc4a4,0.03,false,false,near_mint,en,USD
|
||||
"Dwynen, Gilt-Leaf Daen",FDN,Foundations,217,foil,uncommon,1,100086,01c00d7b-7fac-4f8c-a1ea-de2cf4d06627,0.23,false,false,near_mint,en,USD
|
||||
Swiftfoot Boots,FDN,Foundations,258,foil,uncommon,1,100414,41040541-b129-4cf4-9411-09b1d9d32c19,2.03,false,false,near_mint,en,USD
|
||||
Soul-Shackled Zombie,FDN,Foundations,70,foil,common,1,101609,deea5690-6eb2-4353-b917-cbbf840e4e71,0.05,false,false,near_mint,en,USD
|
||||
Fake Your Own Death,FDN,Foundations,174,foil,common,1,101539,693635a6-df50-44c5-9598-0c79b45d4df4,0.09,false,false,near_mint,en,USD
|
||||
Gnarlid Colony,FDN,Foundations,224,foil,common,1,101508,47565d10-96bf-4fb0-820f-f20a44a76b6f,0.05,false,false,near_mint,en,USD
|
||||
Apothecary Stomper,FDN,Foundations,99,foil,common,1,101537,680b7b0c-0e1b-46ce-9917-9fc6e05aa148,0.02,false,false,near_mint,en,USD
|
||||
Rugged Highlands,FDN,Foundations,265,foil,common,1,101400,fd6eaf8e-8881-4d7b-bafc-75e4ca5cbef6,0.05,false,false,near_mint,en,USD
|
||||
Firebrand Archer,FDN,Foundations,196,foil,common,1,101630,fe0312f1-4c98-4b7f-8a34-0059ea80edef,0.13,false,false,near_mint,en,USD
|
||||
Scoured Barrens,FDN,Foundations,266,foil,common,1,100277,2632a4b2-9ca6-4b67-9a99-14f52ad3dc41,0.12,false,false,near_mint,en,USD
|
||||
Courageous Goblin,FDN,Foundations,82,foil,common,1,101566,8db6819c-666a-409d-85a5-b9ac34d8dd2f,0.02,false,false,near_mint,en,USD
|
||||
Jungle Hollow,FDN,Foundations,263,normal,common,1,101224,dc758e14-d370-45e4-bbc5-938fb4d21127,0.07,false,false,near_mint,en,USD
|
||||
Wind-Scarred Crag,FDN,Foundations,271,normal,common,1,100684,759e99df-11a8-4aee-b6bc-344e84e10d94,0.04,false,false,near_mint,en,USD
|
||||
Dismal Backwater,FDN,Foundations,261,normal,common,1,101220,dbb0df36-8467-4a41-8e1c-6c3584d4fd10,0.06,false,false,near_mint,en,USD
|
||||
Bloodfell Caves,FDN,Foundations,259,normal,common,1,100806,8b90dc92-cb66-41d9-89f9-2b6e3cfc8082,0.05,false,false,near_mint,en,USD
|
||||
Rugged Highlands,FDN,Foundations,265,normal,common,1,101400,fd6eaf8e-8881-4d7b-bafc-75e4ca5cbef6,0.05,false,false,near_mint,en,USD
|
||||
Scavenging Ooze,FDN,Foundations,232,normal,rare,1,100808,8c504c23-1e9a-411b-9cfe-4180d0c744f6,0.15,false,false,near_mint,en,USD
|
||||
"Kiora, the Rising Tide",FDN,Foundations,45,normal,rare,1,100762,83f20a32-9f5d-4a68-8995-549e57554da2,1.57,false,false,near_mint,en,USD
|
||||
Curator of Destinies,FDN,Foundations,34,normal,rare,1,100908,9ff79da7-c3f7-4541-87a0-503544c699b5,0.12,false,false,near_mint,en,USD
|
||||
"Loot, Exuberant Explorer",FDN,Foundations,106,normal,rare,1,100131,09980ce6-425b-4e03-94d0-0f02043cb361,4.8,false,false,near_mint,en,USD
|
||||
Micromancer,FDN,Foundations,158,normal,uncommon,1,101274,e6af54ea-b57a-4e50-8e46-1747cca14430,0.07,false,false,near_mint,en,USD
|
||||
"Ruby, Daring Tracker",FDN,Foundations,245,normal,uncommon,1,101405,fe3e7dd2-b66d-4218-9fde-f84bec26b7bf,0.05,false,false,near_mint,en,USD
|
||||
Mild-Mannered Librarian,FDN,Foundations,228,normal,uncommon,1,100515,5389663a-fe25-41b9-8c92-1f4d7721ffc2,0.03,false,false,near_mint,en,USD
|
||||
Guarded Heir,FDN,Foundations,14,normal,uncommon,1,100505,525ba5c7-3ce5-4e52-b8b5-96c9040a6738,0.05,false,false,near_mint,en,USD
|
||||
Garruk's Uprising,FDN,Foundations,220,normal,uncommon,1,100447,4805c303-e73b-443b-a09f-49d2c2c88bb5,0.25,false,false,near_mint,en,USD
|
||||
Vampire Nighthawk,FDN,Foundations,186,normal,uncommon,1,101474,0a1934ab-3171-4fc6-8033-ad998899ba73,0.12,false,false,near_mint,en,USD
|
||||
Soulstone Sanctuary,FDN,Foundations,133,normal,rare,1,100596,642553a7-6d0f-483d-a873-3a703786db42,1.9,false,false,near_mint,en,USD
|
||||
"Balmor, Battlemage Captain",FDN,Foundations,237,normal,uncommon,1,100142,0b45ab13-9bb6-48af-8b37-d97b25801ac8,0.07,false,false,near_mint,en,USD
|
||||
Adventuring Gear,FDN,Foundations,249,normal,uncommon,1,100358,361f9b99-5b5d-40da-b4b9-5ad90f6280ee,0.06,false,false,near_mint,en,USD
|
||||
Grappling Kraken,FDN,Foundations,39,normal,uncommon,1,101165,d1f5cab3-3fc0-448d-8252-cd55abf5b596,0.12,false,false,near_mint,en,USD
|
||||
Quakestrider Ceratops,FDN,Foundations,110,normal,uncommon,1,100120,067f72c2-ead6-4879-bc9d-696c9f87c0b2,0.11,false,false,near_mint,en,USD
|
||||
Genesis Wave,FDN,Foundations,221,normal,rare,1,101177,d46f7ddb-f986-4f1f-b096-ae1a02d0bdc8,0.29,false,false,near_mint,en,USD
|
||||
"Lathril, Blade of the Elves",FDN,Foundations,242,normal,rare,1,100811,8d4e5480-a287-4a25-b855-a26dae555b1c,0.25,false,false,near_mint,en,USD
|
||||
Elvish Archdruid,FDN,Foundations,219,normal,rare,1,100341,341da856-7414-403b-b2e3-4bebd58a5aa4,0.4,false,false,near_mint,en,USD
|
||||
Imprisoned in the Moon,FDN,Foundations,156,normal,uncommon,1,101313,ee28e147-6622-4399-a314-c14a5c912dd0,0.18,false,false,near_mint,en,USD
|
||||
Inspiring Call,FDN,Foundations,226,normal,uncommon,1,100400,3e241642-5172-4437-b694-f6aa159d5cd9,0.15,false,false,near_mint,en,USD
|
||||
Essence Scatter,FDN,Foundations,153,normal,uncommon,1,101226,dd05c850-f91e-4ffb-b4cc-8418d49dad90,0.04,false,false,near_mint,en,USD
|
||||
Exemplar of Light,FDN,Foundations,11,normal,rare,1,100832,920c8fc5-fdd2-446a-a676-5c363f96928f,2.82,false,false,near_mint,en,USD
|
||||
Meteor Golem,FDN,Foundations,256,normal,uncommon,1,101167,d291ea1e-36bc-46b3-b3ae-084fa0ba69eb,0.05,false,false,near_mint,en,USD
|
||||
Swiftfoot Boots,FDN,Foundations,258,normal,uncommon,1,100414,41040541-b129-4cf4-9411-09b1d9d32c19,1.19,false,false,near_mint,en,USD
|
||||
Brazen Scourge,FDN,Foundations,191,normal,uncommon,1,101616,eb84b86c-3276-4fc1-a09d-47de388cb729,0.02,false,false,near_mint,en,USD
|
||||
Sylvan Scavenging,FDN,Foundations,113,normal,rare,1,101100,c35b683c-d3b2-46a1-876a-81b34e8ba2fc,0.25,false,false,near_mint,en,USD
|
||||
Claws Out,FDN,Foundations,6,normal,uncommon,1,100429,4396049c-b976-4b7f-8ecd-564e24ebd631,0.1,false,false,near_mint,en,USD
|
||||
Snakeskin Veil,FDN,Foundations,233,normal,uncommon,1,100645,6cc4c21d-9bdc-4490-9203-17f51db0ddd1,0.08,false,false,near_mint,en,USD
|
||||
Skyship Buccaneer,FDN,Foundations,50,normal,uncommon,1,100587,62958fc3-55dc-4b97-a070-490d6ed27820,0.02,false,false,near_mint,en,USD
|
||||
Arcane Epiphany,FDN,Foundations,29,normal,uncommon,1,100116,06431793-5dfe-4cbf-990b-4bcc960d1f31,0.03,false,false,near_mint,en,USD
|
||||
Brass's Bounty,FDN,Foundations,190,normal,rare,1,100610,65fe7127-b0ec-400f-97f1-6e17ab8e319d,0.14,false,false,near_mint,en,USD
|
||||
Fiendish Panda,FDN,Foundations,120,normal,uncommon,1,100483,4e434d74-cad0-45f5-bc8d-f34aa5e1d879,0.09,false,false,near_mint,en,USD
|
||||
Frenzied Goblin,FDN,Foundations,199,normal,uncommon,1,101602,d5592573-2889-40b1-b1d5-c2802482549a,0.03,false,false,near_mint,en,USD
|
||||
Lunar Insight,FDN,Foundations,46,normal,rare,1,100958,a9a159f6-fecf-4bdd-b2f8-a9665a5cc32d,0.25,false,false,near_mint,en,USD
|
||||
Twinblade Blessing,FDN,Foundations,26,normal,uncommon,1,101310,ecf01cbe-9fcb-4f35-bc6b-2280620b06ff,0.1,false,false,near_mint,en,USD
|
||||
"Tatyova, Benthic Druid",FDN,Foundations,247,normal,uncommon,1,101301,eabc978a-0666-472d-bdc6-d4b29d29eca4,0.06,false,false,near_mint,en,USD
|
||||
Dragon Trainer,FDN,Foundations,84,normal,uncommon,1,100830,91bd75a1-cb54-4e38-9ce1-e8f32a73c6eb,0.04,false,false,near_mint,en,USD
|
||||
Raise the Past,FDN,Foundations,22,normal,rare,1,100641,6c6be129-56da-4fe7-a6bd-6a1d402c09e1,2.27,false,false,near_mint,en,USD
|
||||
Divine Resilience,FDN,Foundations,10,normal,uncommon,1,101347,f3a08245-a535-4d24-b8c0-78759bb9c4b0,0.11,false,false,near_mint,en,USD
|
||||
Bulk Up,FDN,Foundations,80,normal,uncommon,1,100857,977dcc50-da10-4281-b522-9240c1204f5d,0.2,false,false,near_mint,en,USD
|
||||
Diregraf Ghoul,FDN,Foundations,171,normal,uncommon,1,100439,4682012c-d7e0-4257-b538-3de497507464,0.03,false,false,near_mint,en,USD
|
||||
Drake Hatcher,FDN,Foundations,35,normal,rare,1,101071,bcaf4196-6bf3-47fa-b5c7-0e77f45cf820,0.12,false,false,near_mint,en,USD
|
||||
Youthful Valkyrie,FDN,Foundations,149,normal,uncommon,1,100894,9d795f79-c3a5-4ea1-a5cf-1ce73d6837b6,0.14,false,false,near_mint,en,USD
|
||||
Seeker's Folly,FDN,Foundations,69,normal,uncommon,1,101067,bc359da6-8b7f-45ec-b530-ce159fc35953,0.06,false,false,near_mint,en,USD
|
||||
Heroic Reinforcements,FDN,Foundations,241,normal,uncommon,1,100631,6a05e8d5-c2ad-489a-888d-22622886b620,0.04,false,false,near_mint,en,USD
|
||||
Inspiration from Beyond,FDN,Foundations,43,normal,uncommon,1,101033,b636fe95-664f-4fb1-aab9-28856edeccd6,0.04,false,false,near_mint,en,USD
|
||||
"Dwynen, Gilt-Leaf Daen",FDN,Foundations,217,normal,uncommon,1,100086,01c00d7b-7fac-4f8c-a1ea-de2cf4d06627,0.14,false,false,near_mint,en,USD
|
||||
Twinflame Tyrant,FDN,Foundations,97,normal,mythic,1,100228,1eb34f51-0bd2-43c3-af95-2ce8dabcc7bb,17.77,false,false,near_mint,en,USD
|
||||
Sun-Blessed Healer,FDN,Foundations,25,normal,uncommon,1,100332,323d029e-9a88-4188-b3a4-38ef32cffc9f,0.09,false,false,near_mint,en,USD
|
||||
Seismic Rupture,FDN,Foundations,205,normal,uncommon,1,100268,2519a51a-26a0-4884-9ba8-9db135c9ee49,0.02,false,false,near_mint,en,USD
|
||||
Slumbering Cerberus,FDN,Foundations,94,normal,uncommon,1,100892,9d06faa8-201d-45db-b398-ad56f7b01848,0.03,false,false,near_mint,en,USD
|
||||
Tragic Banshee,FDN,Foundations,73,normal,uncommon,1,100324,30df3e33-2f17-4067-99f1-5db6b0f41fd4,0.03,false,false,near_mint,en,USD
|
||||
Stromkirk Bloodthief,FDN,Foundations,185,normal,uncommon,1,97176,485d6a5a-2054-47d5-91b8-71ce308ed4dc,0.04,false,false,near_mint,en,USD
|
||||
Blanchwood Armor,FDN,Foundations,213,normal,uncommon,1,100237,1fd7ec1a-dafa-42ca-bc25-f6848fb03f60,0.07,false,false,near_mint,en,USD
|
||||
Spectral Sailor,FDN,Foundations,164,normal,uncommon,1,100100,03a49535-c5f3-4a6f-b333-7ac7bffdc9ae,0.06,false,false,near_mint,en,USD
|
||||
Extravagant Replication,FDN,Foundations,154,normal,rare,1,100634,6a41dfae-bc7e-4105-8f7e-fd0109197ad8,0.43,false,false,near_mint,en,USD
|
||||
Electroduplicate,FDN,Foundations,85,normal,rare,1,100976,abb06b1c-5d4e-49b9-9c4a-e60ab656a257,0.3,false,false,near_mint,en,USD
|
||||
Angel of Finality,FDN,Foundations,136,normal,uncommon,1,101057,baaabd52-3aa9-4e2f-9369-d4db8b405ba8,0.07,false,false,near_mint,en,USD
|
||||
Battlesong Berserker,FDN,Foundations,78,normal,uncommon,1,100917,a1f8b199-5d62-485f-b1c3-b30aa550595b,0.03,false,false,near_mint,en,USD
|
||||
Swiftblade Vindicator,FDN,Foundations,246,normal,rare,1,101372,f94618ec-000c-4371-b925-05ff82bfe221,0.12,false,false,near_mint,en,USD
|
||||
Dauntless Veteran,FDN,Foundations,8,normal,uncommon,1,100704,7a136f26-ac66-407f-b389-357222d2c4a2,0.05,false,false,near_mint,en,USD
|
||||
Hero's Downfall,FDN,Foundations,175,normal,uncommon,1,97185,ad2c01d9-8f54-46c0-9dc9-d4d4764ce1c9,0.1,false,false,near_mint,en,USD
|
||||
Resolute Reinforcements,FDN,Foundations,145,normal,uncommon,1,100841,940f3989-77cc-49a9-92e0-095a75d80f0f,0.09,false,false,near_mint,en,USD
|
||||
Zombify,FDN,Foundations,187,normal,uncommon,1,101225,dc798e6f-13c4-457c-b052-b7b65bc83cfe,0.09,false,false,near_mint,en,USD
|
||||
Fiery Annihilation,FDN,Foundations,86,normal,uncommon,1,100523,54fe00aa-d284-48f9-b5a2-1bd4c5fa8e58,0.07,false,false,near_mint,en,USD
|
||||
Clinquant Skymage,FDN,Foundations,33,normal,uncommon,1,100357,36012810-0e83-4640-8ba7-7262229f1b84,0.05,false,false,near_mint,en,USD
|
||||
Consuming Aberration,FDN,Foundations,238,normal,rare,1,101066,bc2b28fd-66b0-457c-80ea-7caed2cc7926,0.16,false,false,near_mint,en,USD
|
||||
Fishing Pole,FDN,Foundations,128,normal,uncommon,1,101128,c95ab836-3277-4223-9aaa-ef2c77256b65,0.07,false,false,near_mint,en,USD
|
||||
Felling Blow,FDN,Foundations,105,normal,uncommon,1,100854,96948ae3-b15d-4d6d-aa73-9f52084cd903,0.05,false,false,near_mint,en,USD
|
||||
Abrade,FDN,Foundations,188,normal,uncommon,1,100522,548947dc-a5ca-43b5-9531-bcef20fa4ae5,0.09,false,false,near_mint,en,USD
|
||||
Spinner of Souls,FDN,Foundations,112,normal,rare,1,101358,f50a8dec-b079-4192-9098-6cdc1026c693,0.66,false,false,near_mint,en,USD
|
||||
Vampire Gourmand,FDN,Foundations,74,normal,uncommon,1,100827,917514c0-9cd5-4b97-85b9-c4f753560ad4,0.09,false,false,near_mint,en,USD
|
||||
Needletooth Pack,FDN,Foundations,108,normal,uncommon,1,100868,993c1679-e02b-44f2-b34e-12fd6b5142e9,0.05,false,false,near_mint,en,USD
|
||||
Burnished Hart,FDN,Foundations,250,normal,uncommon,1,100609,65ebbff0-fbe6-4310-a33f-e00bb2534979,0.06,false,false,near_mint,en,USD
|
||||
Arbiter of Woe,FDN,Foundations,55,normal,uncommon,1,101008,b2496c4a-df03-4583-bd76-f98ed5cb61ee,0.06,false,false,near_mint,en,USD
|
||||
Good-Fortune Unicorn,FDN,Foundations,240,normal,uncommon,1,101300,eabbe163-2b15-42e3-89ce-7363e6250d3a,0.1,false,false,near_mint,en,USD
|
||||
Reassembling Skeleton,FDN,Foundations,182,normal,uncommon,1,100291,28e84b1b-1c05-4e1b-93b8-9cc2ca73509d,0.08,false,false,near_mint,en,USD
|
||||
Reclamation Sage,FDN,Foundations,231,normal,uncommon,1,100197,1918ea65-ab7f-4d40-97fd-a656c892a2a1,0.14,false,false,near_mint,en,USD
|
||||
Leyline Axe,FDN,Foundations,129,normal,rare,1,101052,b9c03336-a321-4c06-94d1-809f328fabd8,3.17,false,false,near_mint,en,USD
|
||||
An Offer You Can't Refuse,FDN,Foundations,160,normal,uncommon,1,100948,a829747f-cf9b-4d81-ba66-9f0630ed4565,0.99,false,false,near_mint,en,USD
|
||||
Goblin Negotiation,FDN,Foundations,88,normal,uncommon,1,101335,f2016585-e26c-4d13-b09f-af6383c192f7,0.09,false,false,near_mint,en,USD
|
||||
Empyrean Eagle,FDN,Foundations,239,normal,uncommon,1,100533,577e99a7-4a55-4314-8f08-2ae0c33b85c7,0.08,false,false,near_mint,en,USD
|
||||
Solemn Simulacrum,FDN,Foundations,257,normal,rare,1,100514,5383f45e-3da2-40fb-beee-801448bbb60f,0.3,false,false,near_mint,en,USD
|
||||
Crystal Barricade,FDN,Foundations,7,normal,rare,1,100822,905d3e02-ea06-45e7-9adb-c8e7583323a2,1.24,false,false,near_mint,en,USD
|
||||
Hidetsugu's Second Rite,FDN,Foundations,202,normal,uncommon,1,100577,609421da-8d89-4365-b18b-778832d91482,0.04,false,false,near_mint,en,USD
|
||||
Affectionate Indrik,FDN,Foundations,211,normal,uncommon,1,100310,2da8347d-06a4-46e0-a55e-cc2da4660263,0.02,false,false,near_mint,en,USD
|
||||
Infernal Vessel,FDN,Foundations,63,normal,uncommon,1,101560,877b6330-2d0b-4f2f-a848-f10b06fb4ef5,0.06,false,false,near_mint,en,USD
|
||||
"Zimone, Paradox Sculptor",FDN,Foundations,126,normal,mythic,1,100241,20ccbfdd-ddae-440c-9bc0-38b15a56fdd1,2.13,false,false,near_mint,en,USD
|
||||
High-Society Hunter,FDN,Foundations,61,normal,rare,1,100501,51da4a4b-ea12-4169-a7cf-eb4427f13e84,0.64,false,false,near_mint,en,USD
|
||||
Heraldic Banner,FDN,Foundations,254,normal,uncommon,1,100678,743ea709-dbb3-4db8-a2ce-544f47eb6339,0.24,false,false,near_mint,en,USD
|
||||
Wardens of the Cycle,FDN,Foundations,125,normal,uncommon,1,100761,83ea9b2c-5723-4eff-88ac-6669975939e3,0.07,false,false,near_mint,en,USD
|
||||
Preposterous Proportions,FDN,Foundations,109,normal,rare,1,100983,acb65189-60e4-42e0-9fb1-da6b716b91d7,0.94,false,false,near_mint,en,USD
|
||||
Savannah Lions,FDN,Foundations,146,normal,uncommon,1,97184,9c9ac1bc-cdf3-4fa6-8319-a7ea164e9e47,0.04,false,false,near_mint,en,USD
|
||||
Secluded Courtyard,FDN,Foundations,267,normal,uncommon,1,101161,d13373d2-139b-48c7-a8c9-828cefc4f150,0.12,false,false,near_mint,en,USD
|
||||
Ajani's Pridemate,FDN,Foundations,135,normal,uncommon,1,100255,222c1a68-e34c-4103-b1be-17d4ceaef6ce,0.06,false,false,near_mint,en,USD
|
||||
"Arahbo, the First Fang",FDN,Foundations,2,normal,rare,1,100503,524a5d93-26ed-436d-a437-dc9460acce98,1.0,false,false,near_mint,en,USD
|
||||
Authority of the Consuls,FDN,Foundations,137,normal,rare,1,100425,42ce2d7f-5924-47c0-b5ed-dacf9f9617a0,5.3,false,false,near_mint,en,USD
|
||||
Nine-Lives Familiar,FDN,Foundations,321,normal,rare,1,100060,6cc1623f-370d-42b5-88a2-039f31e9be0b,2.67,false,false,near_mint,en,USD
|
||||
Ajani's Pridemate,FDN,Foundations,293,foil,uncommon,1,101180,d4cfb9bc-4273-4e5f-a7ac-2006a8345a4e,0.38,false,false,near_mint,en,USD
|
||||
Helpful Hunter,FDN,Foundations,16,foil,common,1,97172,1b9a0e91-80b5-428f-8f08-931d0631be14,1.61,false,false,near_mint,en,USD
|
||||
Felidar Savior,FDN,Foundations,12,foil,common,1,97191,cd092b14-d72f-4de0-8f19-1338661b9e3b,0.05,false,false,near_mint,en,USD
|
||||
Thrill of Possibility,FDN,Foundations,210,normal,common,3,101561,882b348c-076b-41d8-b505-063480636669,0.03,false,false,near_mint,en,USD
|
||||
Lightshell Duo,FDN,Foundations,157,normal,common,7,101063,bb75315c-ea8f-4eb0-899e-c73ef75fc396,0.04,false,false,near_mint,en,USD
|
||||
Mischievous Pup,FDN,Foundations,144,normal,uncommon,2,100670,7214d984-6400-44d7-bde6-57d96b606e78,0.04,false,false,near_mint,en,USD
|
||||
Swiftwater Cliffs,FDN,Foundations,268,normal,common,3,101389,fb88667d-7088-4889-960f-317486ebe856,0.03,false,false,near_mint,en,USD
|
||||
Hare Apparent,FDN,Foundations,15,normal,common,3,100907,9fc6f0e9-eb5f-4bc0-b3d7-756644b66d12,3.62,false,false,near_mint,en,USD
|
||||
Dazzling Angel,FDN,Foundations,9,normal,common,3,101468,027dc444-e544-4693-8653-3dcdda530162,0.1,false,false,near_mint,en,USD
|
||||
Bigfin Bouncer,FDN,Foundations,31,normal,common,3,100882,9b1d5b76-b07e-45c6-800d-4cfce085164f,0.02,false,false,near_mint,en,USD
|
||||
Ambush Wolf,FDN,Foundations,98,normal,common,4,101492,2903832c-318e-42ab-bf58-c682ec2f7afd,0.05,false,false,near_mint,en,USD
|
||||
Healer's Hawk,FDN,Foundations,142,normal,common,3,101595,cc8e4563-04bb-46b5-835e-64ba11c0e972,0.09,false,false,near_mint,en,USD
|
||||
Rune-Sealed Wall,FDN,Foundations,49,normal,uncommon,2,101212,da0f147b-95ed-4f32-9b46-6a633ae31976,0.15,false,false,near_mint,en,USD
|
||||
Pilfer,FDN,Foundations,181,normal,common,4,101564,8c7c88b5-6d09-453b-b9c1-7dcbba8f1080,0.03,false,false,near_mint,en,USD
|
||||
Stab,FDN,Foundations,71,normal,common,3,101538,6859a5ba-1c1c-4631-bba8-f9900b827178,0.04,false,false,near_mint,en,USD
|
||||
Heartfire Immolator,FDN,Foundations,201,normal,uncommon,2,100390,3ca38f4d-01f5-4a02-9000-01261a440dbf,0.03,false,false,near_mint,en,USD
|
||||
Marauding Blight-Priest,FDN,Foundations,178,normal,common,3,101528,5f70dafc-c638-4ec0-ab5b-62998f752720,0.12,false,false,near_mint,en,USD
|
||||
Broken Wings,FDN,Foundations,214,normal,common,3,100584,61f9cbeb-cc9c-4562-be65-8a77053faefe,0.02,false,false,near_mint,en,USD
|
||||
Firespitter Whelp,FDN,Foundations,197,normal,uncommon,2,100463,4b3a4c7d-3126-4bde-9dca-cb6a1e2f37c9,0.15,false,false,near_mint,en,USD
|
||||
Make Your Move,FDN,Foundations,143,normal,common,3,101546,7368f861-3288-4645-90a7-ca35d6da3721,0.03,false,false,near_mint,en,USD
|
||||
Treetop Snarespinner,FDN,Foundations,114,normal,common,4,101562,88e68fa3-159d-49a6-8ac6-afc9bd6f1718,0.06,false,false,near_mint,en,USD
|
||||
Vengeful Bloodwitch,FDN,Foundations,76,normal,uncommon,2,97189,bd0c12dd-f138-45c0-9614-d83a1d8e8399,0.17,false,false,near_mint,en,USD
|
||||
Evolving Wilds,FDN,Foundations,262,normal,common,4,100376,3a0b9356-5b91-4542-8802-f0f7275238e1,0.06,false,false,near_mint,en,USD
|
||||
Bite Down,FDN,Foundations,212,normal,common,3,101625,f8d70b3b-f6f9-4b3c-ad70-0ce369e812b5,0.04,false,false,near_mint,en,USD
|
||||
Elfsworn Giant,FDN,Foundations,103,normal,common,3,100497,5128a5be-ffa6-4998-8488-872d80b24cb2,0.06,false,false,near_mint,en,USD
|
||||
Apothecary Stomper,FDN,Foundations,99,normal,common,3,101537,680b7b0c-0e1b-46ce-9917-9fc6e05aa148,0.05,false,false,near_mint,en,USD
|
||||
Axgard Cavalry,FDN,Foundations,189,normal,common,3,101631,fe3cc41a-adae-4c9b-b4d3-03f3ca862fed,0.03,false,false,near_mint,en,USD
|
||||
Wary Thespian,FDN,Foundations,235,normal,common,3,101574,a3d62d04-0974-4cb5-9a35-5e996c6456e2,0.01,false,false,near_mint,en,USD
|
||||
Fleeting Flight,FDN,Foundations,13,normal,common,3,101513,55139100-9342-41fd-b10a-8e9932e605d4,0.04,false,false,near_mint,en,USD
|
||||
Quick-Draw Katana,FDN,Foundations,130,normal,common,3,101540,69beec98-c89c-4673-953c-8b3ef3d81560,0.07,false,false,near_mint,en,USD
|
||||
Goblin Surprise,FDN,Foundations,200,normal,common,3,101512,527dd5d4-5f72-40bb-8a9d-1f5ac3f81e2e,0.05,false,false,near_mint,en,USD
|
||||
Sower of Chaos,FDN,Foundations,95,normal,common,4,101556,7ff50606-491c-4946-8d03-719b01cfad77,0.01,false,false,near_mint,en,USD
|
||||
Involuntary Employment,FDN,Foundations,203,normal,common,4,101622,f3ad3d62-2f24-4562-b3fa-809213dbc4a4,0.06,false,false,near_mint,en,USD
|
||||
Burst Lightning,FDN,Foundations,192,normal,common,3,100994,aec5d380-d354-4750-931a-6c91853e2edc,0.08,false,false,near_mint,en,USD
|
||||
Banishing Light,FDN,Foundations,138,normal,common,4,101613,e38dc3b3-1629-491b-8afd-0e7a9a857713,0.03,false,false,near_mint,en,USD
|
||||
Blossoming Sands,FDN,Foundations,260,normal,common,2,100364,37676ed8-588c-4bca-8065-874b74d84807,0.05,false,false,near_mint,en,USD
|
||||
Felidar Savior,FDN,Foundations,12,normal,common,3,97191,cd092b14-d72f-4de0-8f19-1338661b9e3b,0.02,false,false,near_mint,en,USD
|
||||
Revenge of the Rats,FDN,Foundations,67,normal,uncommon,2,100232,1f463c55-39a0-4f2f-aae3-0c5540bde5b7,0.12,false,false,near_mint,en,USD
|
||||
Armasaur Guide,FDN,Foundations,3,normal,common,3,101591,c80fc380-0499-4499-8a60-c43844c02c9b,0.03,false,false,near_mint,en,USD
|
||||
Campus Guide,FDN,Foundations,251,normal,common,3,101504,43c59814-3167-4b05-bb85-6c736f3956a4,0.02,false,false,near_mint,en,USD
|
||||
Dreadwing Scavenger,FDN,Foundations,118,normal,uncommon,2,101252,e24d838b-ab48-410a-9a50-dbfea5da089b,0.04,false,false,near_mint,en,USD
|
||||
Gleaming Barrier,FDN,Foundations,252,normal,common,3,101479,1b49b009-e6f2-494a-9235-f5c25c2d70a9,0.06,false,false,near_mint,en,USD
|
||||
Scoured Barrens,FDN,Foundations,266,normal,common,2,100277,2632a4b2-9ca6-4b67-9a99-14f52ad3dc41,0.07,false,false,near_mint,en,USD
|
||||
Erudite Wizard,FDN,Foundations,37,normal,common,3,100835,9273c417-0fcd-4273-b24e-afff76336d0c,0.01,false,false,near_mint,en,USD
|
||||
Gorehorn Raider,FDN,Foundations,89,normal,common,3,101551,78ce6c40-3452-4aa0-a45b-dbfd70f8d220,0.02,false,false,near_mint,en,USD
|
||||
Cackling Prowler,FDN,Foundations,101,normal,common,3,101481,1bd8e971-c075-4203-8d83-c28f22d4f9b9,0.03,false,false,near_mint,en,USD
|
||||
Burglar Rat,FDN,Foundations,170,normal,common,4,101608,de1c8758-ce3d-49cf-8173-c0eb46f5e7bc,0.05,false,false,near_mint,en,USD
|
||||
Mocking Sprite,FDN,Foundations,159,normal,common,3,101624,f6792f63-b651-497d-8aa5-cddf4cedeca8,0.03,false,false,near_mint,en,USD
|
||||
Cathar Commando,FDN,Foundations,139,normal,common,3,100204,19cf024d-edb6-4a79-8676-73f8db0cdf1f,0.06,false,false,near_mint,en,USD
|
||||
Hungry Ghoul,FDN,Foundations,62,normal,common,3,100701,790f9433-7565-4f7f-88e8-8af762ea0296,0.04,false,false,near_mint,en,USD
|
||||
Vampire Soulcaller,FDN,Foundations,75,normal,common,3,101495,2d076293-3b45-4878-8f67-978927cc1f68,0.04,false,false,near_mint,en,USD
|
||||
Exsanguinate,FDN,Foundations,173,normal,uncommon,1,101330,f11d7311-4066-4a5d-ba28-9857fa707a0b,0.4,false,false,near_mint,en,USD
|
||||
Fanatical Firebrand,FDN,Foundations,195,normal,common,3,101598,d1296316-7781-4e98-95e6-7020648be6a5,0.03,false,false,near_mint,en,USD
|
||||
Sanguine Syphoner,FDN,Foundations,68,normal,common,4,101582,b1daf5bb-c8e9-4e79-a532-ca92a9a885cd,0.07,false,false,near_mint,en,USD
|
||||
Boltwave,FDN,Foundations,79,normal,uncommon,2,100810,8d1ec351-5e70-4eb2-b590-6bff94ef8178,4.08,false,false,near_mint,en,USD
|
||||
Nessian Hornbeetle,FDN,Foundations,229,normal,uncommon,2,100395,3d4d93de-85c6-4653-8ddd-d8bf21516d44,0.05,false,false,near_mint,en,USD
|
||||
Goldvein Pick,FDN,Foundations,253,normal,common,3,101572,a241317d-2277-467e-a8f9-aa71c944e244,0.06,false,false,near_mint,en,USD
|
||||
Icewind Elemental,FDN,Foundations,42,normal,common,3,101629,fd0eba76-3829-408b-828f-0b223c884728,0.05,false,false,near_mint,en,USD
|
||||
Fleeting Distraction,FDN,Foundations,155,normal,common,3,101587,c0b86a7b-4912-43a7-ab89-c3432385baa1,0.02,false,false,near_mint,en,USD
|
||||
Faebloom Trick,FDN,Foundations,38,normal,uncommon,2,100148,0c3bee8f-f5be-4404-a696-c902637799c3,0.17,false,false,near_mint,en,USD
|
||||
Brineborn Cutthroat,FDN,Foundations,152,normal,uncommon,2,100986,acf7aafb-931f-49e5-8691-eab8cb34b05e,0.02,false,false,near_mint,en,USD
|
||||
Gutless Plunderer,FDN,Foundations,60,normal,common,3,101567,909d7778-c7f8-4fa4-89f2-8b32e86e96e4,0.05,false,false,near_mint,en,USD
|
||||
Thornwood Falls,FDN,Foundations,269,normal,common,2,100424,42799f51-0f8c-444b-974e-dae281a5c697,0.05,false,false,near_mint,en,USD
|
||||
Tranquil Cove,FDN,Foundations,270,normal,common,2,100719,7c9cabca-5bcc-4b97-b2ac-a345ad3ee43c,0.06,false,false,near_mint,en,USD
|
||||
Fake Your Own Death,FDN,Foundations,174,normal,common,3,101539,693635a6-df50-44c5-9598-0c79b45d4df4,0.05,false,false,near_mint,en,USD
|
||||
Crypt Feaster,FDN,Foundations,59,normal,common,4,100382,3b072811-998a-4a71-b59c-6afecc0dc4b6,0.03,false,false,near_mint,en,USD
|
||||
Incinerating Blast,FDN,Foundations,90,normal,common,3,101603,d58e20ab-c5ca-4295-884d-78efdaa83243,0.03,false,false,near_mint,en,USD
|
||||
Refute,FDN,Foundations,48,normal,common,3,100368,38806934-dd9c-4ad4-a59c-a16dce03a14a,0.06,false,false,near_mint,en,USD
|
||||
Tolarian Terror,FDN,Foundations,167,normal,common,3,100270,2569d4f3-55ed-4f99-9592-34c7df0aab72,0.09,false,false,near_mint,en,USD
|
||||
Joust Through,FDN,Foundations,19,normal,uncommon,2,100767,846adb38-f9bb-4fed-b8ed-36ec7885f989,0.05,false,false,near_mint,en,USD
|
||||
Bake into a Pie,FDN,Foundations,169,normal,common,3,101494,2ab0e660-86a3-4b92-82fa-77dcb5db947d,0.03,false,false,near_mint,en,USD
|
||||
Soul-Shackled Zombie,FDN,Foundations,70,normal,common,4,101609,deea5690-6eb2-4353-b917-cbbf840e4e71,0.04,false,false,near_mint,en,USD
|
||||
Perforating Artist,FDN,Foundations,124,normal,uncommon,2,100674,72980409-53f0-43c1-965e-06f22e7bb608,0.1,false,false,near_mint,en,USD
|
||||
Serra Angel,FDN,Foundations,147,normal,uncommon,2,100391,3cee9303-9d65-45a2-93d4-ef4aba59141b,0.05,false,false,near_mint,en,USD
|
||||
Squad Rallier,FDN,Foundations,24,normal,common,3,101534,65e1ee86-6f08-4aa0-bf63-ae12028ef080,0.04,false,false,near_mint,en,USD
|
||||
Elementalist Adept,FDN,Foundations,36,normal,common,3,101605,d9768cc6-8f53-4922-ae32-376a2f32d719,0.02,false,false,near_mint,en,USD
|
||||
Elvish Regrower,FDN,Foundations,104,normal,uncommon,2,100278,2694e3cd-26ed-4a10-ae55-fb84d7800253,0.09,false,false,near_mint,en,USD
|
||||
Infestation Sage,FDN,Foundations,64,normal,common,3,101601,d40c73de-7a5f-46f2-a70b-449bc8ecfe24,0.07,false,false,near_mint,en,USD
|
||||
Inspiring Paladin,FDN,Foundations,18,normal,common,3,101472,0763be06-25b2-4d6b-ab33-a1af85aeb443,0.02,false,false,near_mint,en,USD
|
||||
Luminous Rebuke,FDN,Foundations,20,normal,common,3,101529,621839e1-2756-4cdc-a25c-5f76ea98dd87,0.07,false,false,near_mint,en,USD
|
||||
Gnarlid Colony,FDN,Foundations,224,normal,common,3,101508,47565d10-96bf-4fb0-820f-f20a44a76b6f,0.02,false,false,near_mint,en,USD
|
||||
Sure Strike,FDN,Foundations,209,normal,common,3,101525,5de6a1e4-5c66-43e6-9f2a-2635bdab03f6,0.03,false,false,near_mint,en,USD
|
||||
Helpful Hunter,FDN,Foundations,16,normal,common,3,97172,1b9a0e91-80b5-428f-8f08-931d0631be14,0.14,false,false,near_mint,en,USD
|
||||
Goblin Boarders,FDN,Foundations,87,normal,common,3,101506,4409a063-bf2a-4a49-803e-3ce6bd474353,0.04,false,false,near_mint,en,USD
|
||||
Macabre Waltz,FDN,Foundations,177,normal,common,3,101509,4d1f3c84-89ba-4426-a80b-d524f172c912,0.03,false,false,near_mint,en,USD
|
||||
Grow from the Ashes,FDN,Foundations,225,normal,common,3,101502,42525f8a-aee7-4811-8f05-471b559c2c4a,0.03,false,false,near_mint,en,USD
|
||||
Stroke of Midnight,FDN,Foundations,148,normal,uncommon,2,100970,ab135925-d924-456d-851a-6ccdaaf27271,0.17,false,false,near_mint,en,USD
|
||||
Eaten Alive,FDN,Foundations,172,normal,common,3,100216,1c4f7b20-b2a8-498c-8c36-dc296863b0b9,0.02,false,false,near_mint,en,USD
|
||||
Aetherize,FDN,Foundations,151,normal,uncommon,2,100225,1e5530fc-0291-4a17-b048-c5d24e6f51d8,0.17,false,false,near_mint,en,USD
|
||||
Giant Growth,FDN,Foundations,223,normal,common,4,101073,bd0bf74e-14c1-4428-88d8-2181a080b5d0,0.03,false,false,near_mint,en,USD
|
||||
Billowing Shriekmass,FDN,Foundations,56,normal,uncommon,2,100711,7b3587a9-0667-4d53-807b-c437bcb1d7b3,0.02,false,false,near_mint,en,USD
|
||||
Think Twice,FDN,Foundations,165,normal,common,4,101202,d88faaa1-eb41-40f7-991c-5c06e1138f3d,0.05,false,false,near_mint,en,USD
|
||||
Beast-Kin Ranger,FDN,Foundations,100,normal,common,3,100082,0102e0be-5783-4825-9489-713b1b1df0b2,0.05,false,false,near_mint,en,USD
|
||||
Spitfire Lagac,FDN,Foundations,208,normal,common,4,101496,30f600cd-b696-4f49-9cbc-5a33aa43d04c,0.02,false,false,near_mint,en,USD
|
||||
Aegis Turtle,FDN,Foundations,150,normal,common,3,101590,c7f2014a-fbc9-447c-a440-e06d01066bb9,0.08,false,false,near_mint,en,USD
|
||||
Firebrand Archer,FDN,Foundations,196,normal,common,3,101630,fe0312f1-4c98-4b7f-8a34-0059ea80edef,0.05,false,false,near_mint,en,USD
|
||||
Shivan Dragon,FDN,Foundations,206,normal,uncommon,2,100236,1fcff1e0-2745-448d-a27b-e31719e222e9,0.05,false,false,near_mint,en,USD
|
||||
Cephalid Inkmage,FDN,Foundations,32,normal,uncommon,2,101040,b7e47680-18c7-4ffb-aac4-c5db6e7095ba,0.05,false,false,near_mint,en,USD
|
||||
Prideful Parent,FDN,Foundations,21,normal,common,3,97188,b742117a-8a72-43b9-b05d-274829d138a2,0.04,false,false,near_mint,en,USD
|
||||
Uncharted Voyage,FDN,Foundations,53,normal,common,4,101611,e0846820-e595-4743-8a28-29c57d728677,0.01,false,false,near_mint,en,USD
|
||||
Eager Trufflesnout,FDN,Foundations,102,normal,uncommon,2,100940,a6e8433d-eb2a-43d1-b59b-7d70ff97c8e7,0.04,false,false,near_mint,en,USD
|
||||
Juggernaut,FDN,Foundations,255,normal,uncommon,2,101351,f4468fff-cd6f-428c-b7a0-ff89f5bbea2e,0.07,false,false,near_mint,en,USD
|
||||
Llanowar Elves,FDN,Foundations,227,normal,common,3,95583,6a0b230b-d391-4998-a3f7-7b158a0ec2cd,0.15,false,false,near_mint,en,USD
|
||||
Overrun,FDN,Foundations,230,normal,uncommon,2,100220,1d8e9cbb-8bf4-4a48-a58e-79deb3abdf7f,0.14,false,false,near_mint,en,USD
|
||||
Crackling Cyclops,FDN,Foundations,83,normal,common,3,101541,6e5b899a-52f7-471b-ad50-4fa6566758fd,0.01,false,false,near_mint,en,USD
|
||||
Mischievous Mystic,FDN,Foundations,47,normal,uncommon,2,100242,20d89cec-528b-4b2a-87db-e11ce0000622,0.14,false,false,near_mint,en,USD
|
||||
Witness Protection,FDN,Foundations,168,normal,common,3,101621,f231e981-0069-43ce-ac1c-c85ced613e93,0.08,false,false,near_mint,en,USD
|
||||
Dwynen's Elite,FDN,Foundations,218,normal,common,3,100800,89d94c28-ea2e-4a3d-935f-6b2d9f2efc7a,0.05,false,false,near_mint,en,USD
|
||||
Bushwhack,FDN,Foundations,215,normal,common,3,101469,03ebdb36-55e0-49dd-a514-785fbeb4ae19,0.1,false,false,near_mint,en,USD
|
||||
Run Away Together,FDN,Foundations,162,normal,common,3,101614,e598eb7b-10dc-49e6-ac60-2fefa987173e,0.05,false,false,near_mint,en,USD
|
||||
Strongbox Raider,FDN,Foundations,96,normal,uncommon,2,101006,b2223eb8-59f9-489b-a3f3-b6496218cb79,0.02,false,false,near_mint,en,USD
|
||||
Vanguard Seraph,FDN,Foundations,28,normal,common,4,101503,4329c861-fc16-4a96-9c03-25af6ac2adc8,0.06,false,false,near_mint,en,USD
|
||||
Self-Reflection,FDN,Foundations,163,normal,uncommon,2,101247,e1e6abc9-25b2-4d51-b519-2525079eab51,0.04,false,false,near_mint,en,USD
|
||||
Strix Lookout,FDN,Foundations,52,normal,common,3,101627,fbd2422e-8e84-4c39-af29-3b4d38baee63,0.03,false,false,near_mint,en,USD
|
||||
Cat Collector,FDN,Foundations,4,normal,uncommon,2,100507,526fe356-bff1-4211-9e88-bf913ac76b1d,0.1,false,false,near_mint,en,USD
|
||||
Courageous Goblin,FDN,Foundations,82,normal,common,3,101566,8db6819c-666a-409d-85a5-b9ac34d8dd2f,0.03,false,false,near_mint,en,USD
|
||||
"Ygra, Eater of All",BLB,Bloomburrow,241,normal,mythic,1,95825,b9ac7673-eae8-4c4b-889e-5025213a6151,11.58,false,false,near_mint,en,USD
|
||||
Lifecreed Duo,BLB,Bloomburrow,20,normal,common,1,95968,ca543405-5e12-48a0-9a77-082ac9bcb2f2,0.06,false,false,near_mint,en,USD
|
||||
Take Out the Trash,BLB,Bloomburrow,156,normal,common,1,95940,7a1c6f00-af4c-4d35-b682-6c0e759df9a5,0.04,false,false,near_mint,en,USD
|
||||
Ravine Raider,BLB,Bloomburrow,106,normal,common,1,96370,874510be-7ecd-4eff-abad-b9594eb4821a,0.02,false,false,near_mint,en,USD
|
||||
Longstalk Brawl,BLB,Bloomburrow,182,normal,common,1,95966,c7ef748c-b5e5-4e7d-bf2e-d3e6c08edb42,0.04,false,false,near_mint,en,USD
|
||||
Valley Floodcaller,BLB,Bloomburrow,79,normal,rare,1,95876,90b12da0-f666-471d-95f5-15d8c9b31c92,2.65,false,false,near_mint,en,USD
|
||||
Bandit's Talent,BLB,Bloomburrow,83,normal,uncommon,1,95917,485dc8d8-9e44-4a0f-9ff6-fa448e232290,0.47,false,false,near_mint,en,USD
|
||||
Brambleguard Veteran,BLB,Bloomburrow,165,normal,uncommon,1,95880,bac9f6f8-6797-4580-9fc4-9a825872e017,0.09,false,false,near_mint,en,USD
|
||||
Mouse Trapper,BLB,Bloomburrow,22,normal,uncommon,1,95948,8ba1bc5a-03e7-44ec-893e-44042cbc02ef,0.04,false,false,near_mint,en,USD
|
||||
Bushy Bodyguard,BLB,Bloomburrow,166,normal,uncommon,1,95997,0de60cf7-fa82-4b6f-9f88-6590fba5c863,0.08,false,false,near_mint,en,USD
|
||||
Valley Mightcaller,BLB,Bloomburrow,202,normal,rare,1,96057,7256451f-0122-452a-88e8-0fb0f6bea3f3,1.01,false,false,near_mint,en,USD
|
||||
Druid of the Spade,BLB,Bloomburrow,170,normal,common,1,96054,6b485cf7-bad0-4824-9ba7-cb112ce4769f,0.02,false,false,near_mint,en,USD
|
||||
Skyskipper Duo,BLB,Bloomburrow,71,normal,common,1,96476,d6844bad-ffbe-4c6e-b438-08562eccea52,0.04,false,false,near_mint,en,USD
|
||||
Osteomancer Adept,BLB,Bloomburrow,103,normal,rare,1,95800,7d8238dd-858f-466c-96de-986bd66861d7,0.36,false,false,near_mint,en,USD
|
||||
Tender Wildguide,BLB,Bloomburrow,196,normal,rare,1,95792,6b8bfa91-adb0-4596-8c16-d8bb64fdb26d,0.49,false,false,near_mint,en,USD
|
||||
Huskburster Swarm,BLB,Bloomburrow,98,normal,uncommon,1,95978,ed2f61d7-4eb0-41c5-8a34-a0793c2abc51,0.13,false,false,near_mint,en,USD
|
||||
Scrapshooter,BLB,Bloomburrow,191,normal,rare,1,96113,c42ab407-e72d-4c48-9a9e-2055b5e71c69,0.38,false,false,near_mint,en,USD
|
||||
Scavenger's Talent,BLB,Bloomburrow,111,normal,rare,1,96084,9a52b7fe-87ae-425b-85fd-b24e6e0395f1,1.54,false,false,near_mint,en,USD
|
||||
Valley Rotcaller,BLB,Bloomburrow,119,normal,rare,1,95781,4da80a9a-b1d5-4fc5-92f7-36946195d0c7,1.45,false,false,near_mint,en,USD
|
||||
Thornplate Intimidator,BLB,Bloomburrow,117,normal,common,1,96019,42f66c4a-feaa-4ba6-aa56-955b43329a9e,0.02,false,false,near_mint,en,USD
|
||||
Bakersbane Duo,BLB,Bloomburrow,163,normal,common,1,96035,5309354f-1ff4-4fa9-9141-01ea2f7588ab,0.1,false,false,near_mint,en,USD
|
||||
Shore Up,BLB,Bloomburrow,69,normal,common,1,96277,4dc3b49e-3674-494c-bdea-4374cefd10f4,0.08,false,false,near_mint,en,USD
|
||||
Emberheart Challenger,BLB,Bloomburrow,133,normal,rare,1,95888,0035082e-bb86-4f95-be48-ffc87fe5286d,4.13,false,false,near_mint,en,USD
|
||||
"Gev, Scaled Scorch",BLB,Bloomburrow,214,normal,rare,1,96001,131ea976-289e-4f32-896d-27bbfd423ba9,0.37,false,false,near_mint,en,USD
|
||||
Starfall Invocation,BLB,Bloomburrow,34,normal,rare,1,95904,2aea38e6-ec58-4091-b27c-2761bdd12b13,0.88,false,false,near_mint,en,USD
|
||||
Tidecaller Mentor,BLB,Bloomburrow,236,normal,uncommon,1,95859,fa10ffac-7cc2-41ef-b8a0-9431923c0542,0.04,false,false,near_mint,en,USD
|
||||
Jackdaw Savior,BLB,Bloomburrow,18,normal,rare,1,96000,121af600-6143-450a-9f87-12ce4833f1ec,0.27,false,false,near_mint,en,USD
|
||||
"Helga, Skittish Seer",BLB,Bloomburrow,217,normal,mythic,1,95914,40339715-22d0-4f99-822b-a00d9824f27a,2.0,false,false,near_mint,en,USD
|
||||
Long River Lurker,BLB,Bloomburrow,57,normal,uncommon,1,95941,7c267719-cd03-4003-b281-e732d5e42a1e,0.1,false,false,near_mint,en,USD
|
||||
Thornvault Forager,BLB,Bloomburrow,197,normal,rare,1,95807,8c2d6b02-a453-40f9-992a-5c5542987cfb,0.65,false,false,near_mint,en,USD
|
||||
Eddymurk Crab,BLB,Bloomburrow,48,normal,uncommon,1,96132,e6d45abe-4962-47d9-a54e-7e623ea8647c,0.18,false,false,near_mint,en,USD
|
||||
Moonstone Harbinger,BLB,Bloomburrow,101,normal,uncommon,1,95922,59e4aa8d-1d06-48db-b205-aa2f1392bbcb,0.03,false,false,near_mint,en,USD
|
||||
Brazen Collector,BLB,Bloomburrow,128,normal,uncommon,1,95873,78b55a58-c669-4dc6-aa63-5d9dff52e613,0.09,false,false,near_mint,en,USD
|
||||
Brightblade Stoat,BLB,Bloomburrow,4,normal,uncommon,1,95882,df7fea2e-7414-4bc8-adb0-9342e174c009,0.07,false,false,near_mint,en,USD
|
||||
Warren Warleader,BLB,Bloomburrow,38,normal,mythic,1,95849,eb5237a0-5ac3-4ded-9f92-5f782a7bbbd7,3.14,false,false,near_mint,en,USD
|
||||
Kitnap,BLB,Bloomburrow,53,normal,rare,1,95739,085be5d1-fd85-46d1-ad39-a8aa75a06a96,0.14,false,false,near_mint,en,USD
|
||||
Fountainport,BLB,Bloomburrow,253,normal,rare,1,96052,658cfcb7-81b7-48c6-9dd2-1663d06108cf,5.77,false,false,near_mint,en,USD
|
||||
Whiskervale Forerunner,BLB,Bloomburrow,40,normal,rare,1,95927,60a78d59-af31-4af9-95aa-2573fe553925,0.17,false,false,near_mint,en,USD
|
||||
Dreamdew Entrancer,BLB,Bloomburrow,211,normal,rare,1,95755,26bd6b0d-8606-4a37-8be3-a852f1a8e99c,0.28,false,false,near_mint,en,USD
|
||||
Playful Shove,BLB,Bloomburrow,145,normal,uncommon,1,95993,07956edf-34c1-4218-9784-ddbca13e380c,0.1,false,false,near_mint,en,USD
|
||||
Feed the Cycle,BLB,Bloomburrow,94,normal,uncommon,1,96067,7e017ff8-2936-4a1b-bece-00004cfbad06,0.12,false,false,near_mint,en,USD
|
||||
Hoarder's Overflow,BLB,Bloomburrow,141,normal,uncommon,1,96112,c2ed5079-07b4-4575-a2c8-5f0cbff888c3,0.04,false,false,near_mint,en,USD
|
||||
Sunspine Lynx,BLB,Bloomburrow,155,normal,rare,1,95875,8995ceaf-b7e0-423c-8f3e-25212d522502,1.8,false,false,near_mint,en,USD
|
||||
Stormcatch Mentor,BLB,Bloomburrow,234,normal,uncommon,1,95813,99754055-6d67-4fde-aff3-41f6af6ea764,0.21,false,false,near_mint,en,USD
|
||||
For the Common Good,BLB,Bloomburrow,172,normal,rare,1,95912,3ec72a27-b622-47d7-bdf3-970ccaef0d2a,0.87,false,false,near_mint,en,USD
|
||||
Dawn's Truce,BLB,Bloomburrow,295,normal,rare,1,95893,0cce7aec-f9b0-461b-8245-5286b741409d,8.43,false,false,near_mint,en,USD
|
||||
"Clement, the Worrywort",BLB,Bloomburrow,329,normal,rare,1,95835,d1a68d51-cd4e-4ee3-abc7-01435085aa26,0.55,false,false,near_mint,en,USD
|
||||
Tender Wildguide,BLB,Bloomburrow,325,normal,rare,1,95760,2dc164c8-62ca-4d59-ae1c-ef273fde9d10,0.63,false,false,near_mint,en,USD
|
||||
Valley Questcaller,BLB,Bloomburrow,299,normal,rare,1,95839,d9f25130-678d-4338-8eb4-b20d2da5bc74,1.0,false,false,near_mint,en,USD
|
||||
Heirloom Epic,BLB,Bloomburrow,246,normal,uncommon,1,96061,7839ce48-0175-494a-ab89-9bdfb7a50cb1,0.06,false,false,near_mint,en,USD
|
||||
Shrike Force,BLB,Bloomburrow,31,normal,uncommon,1,95763,306fec2c-d8b7-4f4b-8f58-10e3b9f3158f,0.14,false,false,near_mint,en,USD
|
||||
Into the Flood Maw,BLB,Bloomburrow,52,normal,uncommon,1,95919,50b9575a-53d9-4df7-b86c-cda021107d3f,1.48,false,false,near_mint,en,USD
|
||||
Salvation Swan,BLB,Bloomburrow,28,normal,rare,1,95635,b2656160-d319-4530-a6e5-c418596c3f12,0.27,false,false,near_mint,en,USD
|
||||
Hired Claw,BLB,Bloomburrow,140,normal,rare,1,95897,1ae41080-0d67-4719-adb2-49bf2a268b6c,2.43,false,false,near_mint,en,USD
|
||||
Starseer Mentor,BLB,Bloomburrow,233,normal,uncommon,1,95791,6b2f6dc5-9fe8-49c1-b24c-1d99ce1da619,0.05,false,false,near_mint,en,USD
|
||||
Mistbreath Elder,BLB,Bloomburrow,184,normal,rare,1,95975,e5246540-5a84-41d8-9e30-8e7a6c0e84e1,0.37,false,false,near_mint,en,USD
|
||||
Hivespine Wolverine,BLB,Bloomburrow,177,normal,uncommon,1,95943,821970a3-a291-4fe9-bb13-dfc54f9c3caf,0.06,false,false,near_mint,en,USD
|
||||
Patchwork Banner,BLB,Bloomburrow,247,normal,uncommon,1,96097,a8a982c8-bc08-44ba-b3ed-9e4b124615d6,4.68,false,false,near_mint,en,USD
|
||||
"Beza, the Bounding Spring",BLB,Bloomburrow,2,normal,mythic,1,95862,fc310a26-b6a0-4e42-98ab-bdfd7b06cb63,9.56,false,false,near_mint,en,USD
|
||||
Essence Channeler,BLB,Bloomburrow,12,normal,rare,1,96042,5aaf7e4c-4d5d-4acc-a834-e6c4a7629408,1.27,false,false,near_mint,en,USD
|
||||
Valley Questcaller,BLB,Bloomburrow,36,normal,rare,1,95826,ba629ca8-a368-4282-8a61-9bf6a5c217f0,1.12,false,false,near_mint,en,USD
|
||||
Conduct Electricity,BLB,Bloomburrow,130,normal,common,1,95906,2f373dd6-2412-453c-85ba-10230dfe473a,0.02,false,false,near_mint,en,USD
|
||||
Glidedive Duo,BLB,Bloomburrow,96,normal,common,1,96026,4831e7ae-54e3-4bd9-b5af-52dc29f81715,0.02,false,false,near_mint,en,USD
|
||||
Mind Spiral,BLB,Bloomburrow,59,normal,common,1,96068,7e24fe6a-607b-49b8-9fca-cecb1e40de7f,0.01,false,false,near_mint,en,USD
|
||||
Starforged Sword,BLB,Bloomburrow,249,normal,uncommon,1,96110,c23d8e96-b972-4c6c-b0c4-b6627621f048,0.03,false,false,near_mint,en,USD
|
||||
Vinereap Mentor,BLB,Bloomburrow,238,normal,uncommon,1,95902,29b615ba-45c4-42a1-8525-1535f0b55300,0.16,false,false,near_mint,en,USD
|
||||
Mindwhisker,BLB,Bloomburrow,60,normal,uncommon,1,96099,aaa10f34-5bfd-4d87-8f07-58de3b0f5663,0.08,false,false,near_mint,en,USD
|
||||
Persistent Marshstalker,BLB,Bloomburrow,104,normal,uncommon,1,95947,8b900c71-713b-4b7e-b4be-ad9f4aa0c139,0.13,false,false,near_mint,en,USD
|
||||
Portent of Calamity,BLB,Bloomburrow,66,normal,rare,1,96073,8599e2dd-9164-4da3-814f-adccef3b9497,0.14,false,false,near_mint,en,USD
|
||||
Fabled Passage,BLB,Bloomburrow,252,normal,rare,1,96075,8809830f-d8e1-4603-9652-0ad8b00234e9,5.13,false,false,near_mint,en,USD
|
||||
Stormsplitter,BLB,Bloomburrow,154,normal,mythic,1,96040,56f214d3-6b93-40db-a693-55e491c8a283,3.12,false,false,near_mint,en,USD
|
||||
Stargaze,BLB,Bloomburrow,114,normal,uncommon,1,95939,777fc599-8de7-44d2-8fdd-9bddf5948a0c,0.14,false,false,near_mint,en,USD
|
||||
Coruscation Mage,BLB,Bloomburrow,131,normal,uncommon,1,95972,dc2c1de0-6233-469a-be72-a050b97d2c8f,0.32,false,false,near_mint,en,USD
|
||||
Dour Port-Mage,BLB,Bloomburrow,47,normal,rare,1,96049,6402133e-eed1-4a46-9667-8b7a310362c1,2.17,false,false,near_mint,en,USD
|
||||
"Muerra, Trash Tactician",BLB,Bloomburrow,227,normal,rare,1,95821,b40e4658-fd68-46d0-9a89-25570a023d19,0.31,false,false,near_mint,en,USD
|
||||
Stormchaser's Talent,BLB,Bloomburrow,75,normal,rare,1,96092,a36e682d-b43d-4e08-bf5b-70d7e924dbe5,13.62,false,false,near_mint,en,USD
|
||||
Sinister Monolith,BLB,Bloomburrow,113,normal,uncommon,1,96012,2a15e06c-2608-4e7a-a16c-d35417669d86,0.08,false,false,near_mint,en,USD
|
||||
Pawpatch Formation,BLB,Bloomburrow,186,normal,uncommon,1,95963,b82c20ad-0f69-4822-ae76-770832cccdf7,1.83,false,false,near_mint,en,USD
|
||||
Plumecreed Mentor,BLB,Bloomburrow,228,normal,uncommon,1,95819,b1aa988f-547e-449a-9f1a-296c01d68d96,0.03,false,false,near_mint,en,USD
|
||||
"Baylen, the Haymaker",BLB,Bloomburrow,205,normal,rare,1,95889,00e93be2-e06b-4774-8ba5-ccf82a6da1d8,1.04,false,false,near_mint,en,USD
|
||||
Long River's Pull,BLB,Bloomburrow,58,normal,uncommon,1,95900,1c81d0fa-81a1-4f9b-a5fd-5a648fd01dea,0.23,false,false,near_mint,en,USD
|
||||
Bonecache Overseer,BLB,Bloomburrow,85,normal,uncommon,1,95944,82defb87-237f-4b77-9673-5bf00607148f,0.08,false,false,near_mint,en,USD
|
||||
Three Tree Scribe,BLB,Bloomburrow,199,normal,uncommon,1,95977,ea2ca1b3-4c1a-4be5-b321-f57db5ff0528,0.15,false,false,near_mint,en,USD
|
||||
Cruelclaw's Heist,BLB,Bloomburrow,88,normal,rare,1,96121,cab4539a-0157-4cbe-b50f-6e2575df74e9,0.48,false,false,near_mint,en,USD
|
||||
Manifold Mouse,BLB,Bloomburrow,143,normal,rare,1,95881,db3832b5-e83f-4569-bd49-fb7b86fa2d47,3.37,false,false,near_mint,en,USD
|
||||
Iridescent Vinelasher,BLB,Bloomburrow,99,normal,rare,1,95877,b2bc854c-4e72-48e0-a098-e3451d6e511d,1.11,false,false,near_mint,en,USD
|
||||
Daggerfang Duo,BLB,Bloomburrow,89,normal,common,1,96468,cea2bb34-e328-44fb-918a-72208c9457e4,0.03,false,false,near_mint,en,USD
|
||||
Stickytongue Sentinel,BLB,Bloomburrow,193,normal,common,1,96105,b5fa9651-b217-4f93-9c46-9bdb11feedcb,0.03,false,false,near_mint,en,USD
|
||||
Brave-Kin Duo,BLB,Bloomburrow,3,normal,common,1,95824,b8dd4693-424d-4d6e-86cf-24401a23d6b1,0.03,false,false,near_mint,en,USD
|
||||
Driftgloom Coyote,BLB,Bloomburrow,11,normal,uncommon,1,95969,d7ab2de3-3aea-461a-a74f-fb742cf8a198,0.03,false,false,near_mint,en,USD
|
||||
Rockface Village,BLB,Bloomburrow,259,normal,uncommon,1,95629,62799d24-39a6-4e66-8ac3-7cafa99e6e6d,0.48,false,false,near_mint,en,USD
|
||||
Flamecache Gecko,BLB,Bloomburrow,135,normal,uncommon,1,96142,fb8e7c97-8393-41b8-bb0b-3983dcc5e7f4,0.08,false,false,near_mint,en,USD
|
||||
Innkeeper's Talent,BLB,Bloomburrow,180,normal,rare,1,95954,941b0afc-0e8f-45f2-ae7f-07595e164611,19.36,false,false,near_mint,en,USD
|
||||
Repel Calamity,BLB,Bloomburrow,27,foil,uncommon,1,95834,d068192a-6270-4981-819d-4945fa4a2b83,0.08,false,false,near_mint,en,USD
|
||||
Galewind Moose,BLB,Bloomburrow,173,foil,uncommon,1,95871,58706bd8-558a-43b9-9f1e-c1ff0044203b,0.14,false,false,near_mint,en,USD
|
||||
Brave-Kin Duo,BLB,Bloomburrow,3,foil,common,1,95824,b8dd4693-424d-4d6e-86cf-24401a23d6b1,0.06,false,false,near_mint,en,USD
|
||||
Agate Assault,BLB,Bloomburrow,122,foil,common,1,96066,7dd9946b-515e-4e0d-9da2-711e126e9fa6,0.03,false,false,near_mint,en,USD
|
||||
Flamecache Gecko,BLB,Bloomburrow,135,foil,uncommon,1,96142,fb8e7c97-8393-41b8-bb0b-3983dcc5e7f4,0.12,false,false,near_mint,en,USD
|
||||
Rabid Gnaw,BLB,Bloomburrow,147,foil,uncommon,1,96014,2f815bae-820a-49f6-8eed-46f658e7b6ff,0.1,false,false,near_mint,en,USD
|
||||
Pond Prophet,BLB,Bloomburrow,229,foil,common,1,95861,fb959e74-61ea-453d-bb9f-ad0183c0e1b1,0.16,false,false,near_mint,en,USD
|
||||
Star Charter,BLB,Bloomburrow,33,foil,uncommon,1,95894,0e209237-00f7-4bf0-8287-ccde02ce8e8d,0.12,false,false,near_mint,en,USD
|
||||
Kindlespark Duo,BLB,Bloomburrow,142,foil,common,1,96096,a839fba3-1b66-4dd1-bf43-9b015b44fc81,0.07,false,false,near_mint,en,USD
|
||||
Crumb and Get It,BLB,Bloomburrow,8,foil,common,1,96259,3c7b3b25-d4b3-4451-9f5c-6eb369541175,0.04,false,false,near_mint,en,USD
|
||||
Peerless Recycling,BLB,Bloomburrow,188,foil,uncommon,1,95925,5f72466c-505b-4371-9366-0fde525a37e6,0.23,false,false,near_mint,en,USD
|
||||
Nocturnal Hunger,BLB,Bloomburrow,102,foil,common,1,96060,742c0409-9abd-4559-b52e-932cc90c531a,0.02,false,false,near_mint,en,USD
|
||||
Seedpod Squire,BLB,Bloomburrow,232,foil,common,1,95852,f3684577-51ce-490e-9b59-b19c733be466,0.03,false,false,near_mint,en,USD
|
||||
Nettle Guard,BLB,Bloomburrow,23,foil,common,1,95949,8c9c3cc3-2aa2-453e-a17c-2baeeaabe0a9,0.05,false,false,near_mint,en,USD
|
||||
Sazacap's Brew,BLB,Bloomburrow,151,foil,common,1,96330,6d963080-b3ec-467d-82f7-39db6ecd6bbc,0.05,false,false,near_mint,en,USD
|
||||
Waterspout Warden,BLB,Bloomburrow,80,foil,common,1,95909,35898b39-98e2-405b-8f18-0e054bd2c29e,0.04,false,false,near_mint,en,USD
|
||||
Mindwhisker,BLB,Bloomburrow,60,foil,uncommon,1,96099,aaa10f34-5bfd-4d87-8f07-58de3b0f5663,0.12,false,false,near_mint,en,USD
|
||||
Splash Portal,BLB,Bloomburrow,74,foil,uncommon,1,95958,adbaa356-28ba-487f-930a-a957d9960ab0,0.28,false,false,near_mint,en,USD
|
||||
Festival of Embers,BLB,Bloomburrow,134,foil,rare,1,96023,4433ee12-2013-4fdc-979f-ae065f63a527,0.2,false,false,near_mint,en,USD
|
||||
Brightblade Stoat,BLB,Bloomburrow,4,foil,uncommon,1,95882,df7fea2e-7414-4bc8-adb0-9342e174c009,0.11,false,false,near_mint,en,USD
|
||||
Mind Spiral,BLB,Bloomburrow,59,foil,common,1,96068,7e24fe6a-607b-49b8-9fca-cecb1e40de7f,0.04,false,false,near_mint,en,USD
|
||||
Rust-Shield Rampager,BLB,Bloomburrow,190,foil,common,1,96117,c96b01f5-83de-4237-a68d-f946c53e31a6,0.04,false,false,near_mint,en,USD
|
||||
Barkform Harvester,BLB,Bloomburrow,243,foil,common,1,95984,f77049a6-0f22-415b-bc89-20bcb32accf6,0.11,false,false,near_mint,en,USD
|
||||
Wax-Wane Witness,BLB,Bloomburrow,39,foil,common,1,95971,d90ea719-5320-46c6-a347-161853a14776,0.05,false,false,near_mint,en,USD
|
||||
Warren Elder,BLB,Bloomburrow,37,foil,common,1,96030,4bf20069-5a20-4f95-976b-6af2b69f3ad0,0.04,false,false,near_mint,en,USD
|
||||
Stickytongue Sentinel,BLB,Bloomburrow,193,foil,common,1,96105,b5fa9651-b217-4f93-9c46-9bdb11feedcb,0.05,false,false,near_mint,en,USD
|
||||
"Vren, the Relentless",BLB,Bloomburrow,239,foil,rare,1,95930,6506277d-f031-4db5-9d16-bf2389094785,0.71,false,false,near_mint,en,USD
|
||||
Three Tree Scribe,BLB,Bloomburrow,199,foil,uncommon,1,95977,ea2ca1b3-4c1a-4be5-b321-f57db5ff0528,0.2,false,false,near_mint,en,USD
|
||||
Glidedive Duo,BLB,Bloomburrow,96,foil,common,1,96026,4831e7ae-54e3-4bd9-b5af-52dc29f81715,0.03,false,false,near_mint,en,USD
|
||||
Bushy Bodyguard,BLB,Bloomburrow,166,foil,uncommon,1,95997,0de60cf7-fa82-4b6f-9f88-6590fba5c863,0.12,false,false,near_mint,en,USD
|
||||
Conduct Electricity,BLB,Bloomburrow,130,foil,common,1,95906,2f373dd6-2412-453c-85ba-10230dfe473a,0.03,false,false,near_mint,en,USD
|
||||
Daggerfang Duo,BLB,Bloomburrow,89,foil,common,1,96468,cea2bb34-e328-44fb-918a-72208c9457e4,0.07,false,false,near_mint,en,USD
|
||||
Shore Up,BLB,Bloomburrow,69,foil,common,1,96277,4dc3b49e-3674-494c-bdea-4374cefd10f4,0.13,false,false,near_mint,en,USD
|
||||
Hidden Grotto,BLB,Bloomburrow,254,foil,common,1,95918,4ba8f2e7-8357-4862-97dc-1942d066023a,0.17,false,false,near_mint,en,USD
|
||||
Cindering Cutthroat,BLB,Bloomburrow,208,foil,common,1,95820,b2ea10dd-21ea-4622-be27-79d03a802b85,0.01,false,false,near_mint,en,USD
|
||||
"Glarb, Calamity's Augur",BLB,Bloomburrow,215,foil,mythic,1,95864,ffc70b2d-5a3a-49ea-97db-175a62248302,4.3,false,false,near_mint,en,USD
|
||||
Kindlespark Duo,BLB,Bloomburrow,142,normal,common,5,96096,a839fba3-1b66-4dd1-bf43-9b015b44fc81,0.04,false,false,near_mint,en,USD
|
||||
Finch Formation,BLB,Bloomburrow,50,normal,common,2,95899,1c671eab-d1ef-4d79-94eb-8b85f0d18699,0.02,false,false,near_mint,en,USD
|
||||
Builder's Talent,BLB,Bloomburrow,5,normal,uncommon,2,96002,15fa581a-724e-4196-a9a3-ff84c54bdb7d,0.08,false,false,near_mint,en,USD
|
||||
Might of the Meek,BLB,Bloomburrow,144,normal,common,9,95627,509bf254-8a2b-4dfa-9ae5-386321b35e8b,0.09,false,false,near_mint,en,USD
|
||||
Nightwhorl Hermit,BLB,Bloomburrow,62,normal,common,3,95994,0928e04f-2568-41e8-b603-7a25cf5f94d0,0.02,false,false,near_mint,en,USD
|
||||
Fell,BLB,Bloomburrow,95,normal,uncommon,2,95830,c96ac326-de44-470b-a592-a4c2a052c091,0.3,false,false,near_mint,en,USD
|
||||
Sunshower Druid,BLB,Bloomburrow,195,normal,common,6,95630,7740abc5-54e1-478d-966e-0fa64e727995,0.04,false,false,near_mint,en,USD
|
||||
Wandertale Mentor,BLB,Bloomburrow,240,normal,uncommon,2,95808,8c399a55-d02e-41ed-b827-8784b738c118,0.09,false,false,near_mint,en,USD
|
||||
Thought-Stalker Warlock,BLB,Bloomburrow,118,normal,uncommon,2,96018,42e80284-d489-493b-ae92-95b742d07cb3,0.12,false,false,near_mint,en,USD
|
||||
Splash Portal,BLB,Bloomburrow,74,normal,uncommon,2,95958,adbaa356-28ba-487f-930a-a957d9960ab0,0.23,false,false,near_mint,en,USD
|
||||
Alania's Pathmaker,BLB,Bloomburrow,123,normal,common,7,96123,d3871fe6-e26e-4ab4-bd81-7e3c7b8135c1,0.02,false,false,near_mint,en,USD
|
||||
Head of the Homestead,BLB,Bloomburrow,216,normal,common,3,95762,2fc20157-edd3-484d-8864-925c071c0551,0.04,false,false,near_mint,en,USD
|
||||
Hidden Grotto,BLB,Bloomburrow,254,normal,common,4,95918,4ba8f2e7-8357-4862-97dc-1942d066023a,0.08,false,false,near_mint,en,USD
|
||||
Star Charter,BLB,Bloomburrow,33,normal,uncommon,3,95894,0e209237-00f7-4bf0-8287-ccde02ce8e8d,0.04,false,false,near_mint,en,USD
|
||||
War Squeak,BLB,Bloomburrow,160,normal,common,4,95999,105964a7-88b7-4340-aa66-e908189a3638,0.02,false,false,near_mint,en,USD
|
||||
Bellowing Crier,BLB,Bloomburrow,42,normal,common,2,96119,ca2215dd-6300-49cf-b9b2-3a840b786c31,0.04,false,false,near_mint,en,USD
|
||||
Cindering Cutthroat,BLB,Bloomburrow,208,normal,common,4,95820,b2ea10dd-21ea-4622-be27-79d03a802b85,0.02,false,false,near_mint,en,USD
|
||||
Intrepid Rabbit,BLB,Bloomburrow,17,normal,common,7,96276,4d70b99d-c8bf-4a56-8957-cf587fe60b81,0.03,false,false,near_mint,en,USD
|
||||
Carrot Cake,BLB,Bloomburrow,7,normal,common,3,95636,eb03bb4f-8b4b-417e-bfc6-294cd2186b2e,0.06,false,false,near_mint,en,USD
|
||||
Thought Shucker,BLB,Bloomburrow,77,normal,common,7,95916,44b0d83b-cc41-4f82-892c-ef6d3293228a,0.02,false,false,near_mint,en,USD
|
||||
Seasoned Warrenguard,BLB,Bloomburrow,30,normal,uncommon,2,96081,90873995-876f-4e89-8bc7-41a74f4d931f,0.09,false,false,near_mint,en,USD
|
||||
Junkblade Bruiser,BLB,Bloomburrow,220,normal,common,3,95810,918fd89b-5ab7-4ae2-920c-faca5e9da7b9,0.04,false,false,near_mint,en,USD
|
||||
Cache Grab,BLB,Bloomburrow,167,normal,common,2,95842,dfd977dc-a7c3-4d0a-aca7-b25bd154e963,0.08,false,false,near_mint,en,USD
|
||||
Lilypad Village,BLB,Bloomburrow,255,normal,uncommon,2,95631,7e95a7cc-ed77-4ca4-80db-61c0fc68bf50,0.14,false,false,near_mint,en,USD
|
||||
Agate-Blade Assassin,BLB,Bloomburrow,82,normal,common,5,96017,39ebb84a-1c52-4b07-9bd0-b360523b3a5b,0.03,false,false,near_mint,en,USD
|
||||
Repel Calamity,BLB,Bloomburrow,27,normal,uncommon,2,95834,d068192a-6270-4981-819d-4945fa4a2b83,0.07,false,false,near_mint,en,USD
|
||||
Hazel's Nocturne,BLB,Bloomburrow,97,normal,uncommon,2,96009,239363df-4de8-4b64-80fc-a1f4b5c36027,0.07,false,false,near_mint,en,USD
|
||||
Treeguard Duo,BLB,Bloomburrow,200,normal,common,4,96077,89c8456e-c971-42b7-abf3-ff5ae1320abe,0.01,false,false,near_mint,en,USD
|
||||
Calamitous Tide,BLB,Bloomburrow,43,normal,uncommon,2,96003,178bc8b2-ffa0-4549-aead-aacb3db3cf19,0.03,false,false,near_mint,en,USD
|
||||
Splash Lasher,BLB,Bloomburrow,73,normal,uncommon,2,95910,362ee125-35a0-46cd-a201-e6797d12d33a,0.04,false,false,near_mint,en,USD
|
||||
Blooming Blast,BLB,Bloomburrow,126,normal,uncommon,2,95996,0cd92a83-cec3-4085-a929-3f204e3e0140,0.06,false,false,near_mint,en,USD
|
||||
Sugar Coat,BLB,Bloomburrow,76,normal,uncommon,2,95887,fcacbe71-efb0-49e1-b2d0-3ee65ec6cf8b,0.05,false,false,near_mint,en,USD
|
||||
Dazzling Denial,BLB,Bloomburrow,45,normal,common,6,96369,8739f1ac-2e57-4b52-a7ff-cc8df5936aad,0.04,false,false,near_mint,en,USD
|
||||
Nettle Guard,BLB,Bloomburrow,23,normal,common,4,95949,8c9c3cc3-2aa2-453e-a17c-2baeeaabe0a9,0.03,false,false,near_mint,en,USD
|
||||
Raccoon Rallier,BLB,Bloomburrow,148,normal,common,5,96104,b5b5180f-5a1c-4df8-9019-195e65a50ce3,0.04,false,false,near_mint,en,USD
|
||||
High Stride,BLB,Bloomburrow,176,normal,common,8,96153,09c8cf4b-8e65-4a1c-b458-28b5ab56b390,0.04,false,false,near_mint,en,USD
|
||||
Otterball Antics,BLB,Bloomburrow,63,normal,uncommon,2,95913,3ff83ff7-e428-4ccc-8341-f223dab76bd1,0.1,false,false,near_mint,en,USD
|
||||
Frilled Sparkshooter,BLB,Bloomburrow,136,normal,common,7,95934,674bbd6d-e329-42cf-963d-88d1ce8fe51e,0.02,false,false,near_mint,en,USD
|
||||
Moonrise Cleric,BLB,Bloomburrow,226,normal,common,3,95767,35f2a71f-31e8-4b51-9dd4-51a5336b3b86,0.04,false,false,near_mint,en,USD
|
||||
Wax-Wane Witness,BLB,Bloomburrow,39,normal,common,3,95971,d90ea719-5320-46c6-a347-161853a14776,0.02,false,false,near_mint,en,USD
|
||||
Pearl of Wisdom,BLB,Bloomburrow,64,normal,common,7,95625,13cb9575-1138-4f99-8e90-0eaf00bdf4a1,0.01,false,false,near_mint,en,USD
|
||||
Run Away Together,BLB,Bloomburrow,67,normal,common,3,95799,7cb7ec70-a5a4-4188-ba1a-e88b81bdbad0,0.04,false,false,near_mint,en,USD
|
||||
Early Winter,BLB,Bloomburrow,93,normal,common,2,95626,5030e6ac-211d-4145-8c87-998a8351a467,0.05,false,false,near_mint,en,USD
|
||||
Three Tree Rootweaver,BLB,Bloomburrow,198,normal,common,2,96469,d1ab6e14-26e0-4174-b5c6-bc0f5c26b177,0.04,false,false,near_mint,en,USD
|
||||
Mudflat Village,BLB,Bloomburrow,257,normal,uncommon,2,95628,53ec4ad3-9cf0-4f1b-a9db-d63feee594ab,0.24,false,false,near_mint,en,USD
|
||||
Starlit Soothsayer,BLB,Bloomburrow,115,normal,common,6,95895,184c1eca-2991-438f-b5d2-cd2529b9c9b4,0.03,false,false,near_mint,en,USD
|
||||
Hop to It,BLB,Bloomburrow,16,normal,uncommon,2,95851,ee7207f8-5daa-42af-aeea-7a489047110b,0.07,false,false,near_mint,en,USD
|
||||
Psychic Whorl,BLB,Bloomburrow,105,normal,common,5,96127,df900308-8432-4a0a-be21-17482026012b,0.04,false,false,near_mint,en,USD
|
||||
Barkform Harvester,BLB,Bloomburrow,243,normal,common,4,95984,f77049a6-0f22-415b-bc89-20bcb32accf6,0.06,false,false,near_mint,en,USD
|
||||
Daring Waverider,BLB,Bloomburrow,44,normal,uncommon,2,95896,19422406-0c1a-497e-bed1-708bc556491a,0.06,false,false,near_mint,en,USD
|
||||
Plumecreed Escort,BLB,Bloomburrow,65,normal,uncommon,2,95983,f71320ed-2f30-49ce-bcb0-19aebba3f0e8,0.05,false,false,near_mint,en,USD
|
||||
Parting Gust,BLB,Bloomburrow,24,normal,uncommon,2,95744,1086e826-94b8-4398-8a38-d8eacca56a43,0.38,false,false,near_mint,en,USD
|
||||
Veteran Guardmouse,BLB,Bloomburrow,237,normal,common,3,95771,3db43c46-b616-4ef8-80ed-0fab345ab3d0,0.01,false,false,near_mint,en,USD
|
||||
Dire Downdraft,BLB,Bloomburrow,46,normal,common,6,96526,f1931f22-974c-43ad-911e-684bf3f9995d,0.02,false,false,near_mint,en,USD
|
||||
Waterspout Warden,BLB,Bloomburrow,80,normal,common,4,95909,35898b39-98e2-405b-8f18-0e054bd2c29e,0.01,false,false,near_mint,en,USD
|
||||
Lupinflower Village,BLB,Bloomburrow,256,normal,uncommon,2,95634,8ab9d56f-9178-4ec9-a5f6-b934f50d8d9d,0.1,false,false,near_mint,en,USD
|
||||
Heartfire Hero,BLB,Bloomburrow,138,normal,uncommon,2,95870,48ace959-66b2-40c8-9bff-fd7ed9c99a82,2.1,false,false,near_mint,en,USD
|
||||
Peerless Recycling,BLB,Bloomburrow,188,normal,uncommon,2,95925,5f72466c-505b-4371-9366-0fde525a37e6,0.1,false,false,near_mint,en,USD
|
||||
Pond Prophet,BLB,Bloomburrow,229,normal,common,4,95861,fb959e74-61ea-453d-bb9f-ad0183c0e1b1,0.09,false,false,near_mint,en,USD
|
||||
Crumb and Get It,BLB,Bloomburrow,8,normal,common,2,96259,3c7b3b25-d4b3-4451-9f5c-6eb369541175,0.03,false,false,near_mint,en,USD
|
||||
Wildfire Howl,BLB,Bloomburrow,162,normal,uncommon,2,96059,7392d397-9836-4df2-944d-c930c9566811,0.05,false,false,near_mint,en,USD
|
||||
Bark-Knuckle Boxer,BLB,Bloomburrow,164,normal,uncommon,2,95921,582637a9-6aa0-4824-bed7-d5fc91bda35e,0.03,false,false,near_mint,en,USD
|
||||
Ruthless Negotiation,BLB,Bloomburrow,108,normal,uncommon,2,95828,c7f4360c-8d68-4058-b9ec-da9948cb060d,0.1,false,false,near_mint,en,USD
|
||||
Three Tree Mascot,FDN,Foundations,682,normal,common,3,100412,40b8bf3a-1cb5-4ce2-ac25-9410f17130de,0.11,false,false,near_mint,en,USD
|
||||
Tempest Angler,BLB,Bloomburrow,235,normal,common,2,95803,850daae4-f0b7-4604-95e7-ad044ec165c3,0.04,false,false,near_mint,en,USD
|
||||
Starscape Cleric,BLB,Bloomburrow,116,normal,uncommon,2,96037,53a938a7-0154-4350-87cb-00da24ec3824,0.62,false,false,near_mint,en,USD
|
||||
Wick's Patrol,BLB,Bloomburrow,121,normal,uncommon,3,95926,5fa0c53d-fe7b-4b8b-ad81-7967ca318ff7,0.07,false,false,near_mint,en,USD
|
||||
Fireglass Mentor,BLB,Bloomburrow,213,normal,uncommon,2,95823,b78fbaa3-c580-4290-9c28-b74169aab2fc,0.08,false,false,near_mint,en,USD
|
||||
Steampath Charger,BLB,Bloomburrow,153,normal,common,2,95890,03bf1296-e347-4070-8c6f-5c362c2f9364,0.03,false,false,near_mint,en,USD
|
||||
Whiskerquill Scribe,BLB,Bloomburrow,161,normal,common,2,96124,da653996-9bd4-40bd-afb4-48c7e070a269,0.01,false,false,near_mint,en,USD
|
||||
Lilysplash Mentor,BLB,Bloomburrow,222,normal,uncommon,3,95789,64de7b1f-a03e-4407-91f1-e108a2f26735,0.12,false,false,near_mint,en,USD
|
||||
Roughshod Duo,BLB,Bloomburrow,150,normal,common,3,96343,78cdcfb9-a247-4c2d-a098-5b57570f8cd5,0.03,false,false,near_mint,en,USD
|
||||
Bonebind Orator,BLB,Bloomburrow,84,normal,common,3,96535,faf226fa-ca09-4468-8804-87b2a7de2c66,0.02,false,false,near_mint,en,USD
|
||||
Agate Assault,BLB,Bloomburrow,122,normal,common,2,96066,7dd9946b-515e-4e0d-9da2-711e126e9fa6,0.02,false,false,near_mint,en,USD
|
||||
Nocturnal Hunger,BLB,Bloomburrow,102,normal,common,3,96060,742c0409-9abd-4559-b52e-932cc90c531a,0.02,false,false,near_mint,en,USD
|
||||
Jolly Gerbils,BLB,Bloomburrow,19,normal,uncommon,2,96167,0eab51d6-ba17-4a8c-8834-25db363f2b6b,0.04,false,false,near_mint,en,USD
|
||||
Downwind Ambusher,BLB,Bloomburrow,92,normal,uncommon,2,95920,55cfd628-933a-4d3d-b2e5-70bc86960d1c,0.02,false,false,near_mint,en,USD
|
||||
Scales of Shale,BLB,Bloomburrow,110,normal,common,2,95955,9ae14276-dbbd-4257-80e9-accd6c19f5b2,0.02,false,false,near_mint,en,USD
|
||||
Treetop Sentries,BLB,Bloomburrow,201,normal,common,4,95974,e16d4d6e-1fe5-4ff6-9877-8c849a24f5e0,0.03,false,false,near_mint,en,USD
|
||||
Seedpod Squire,BLB,Bloomburrow,232,normal,common,4,95852,f3684577-51ce-490e-9b59-b19c733be466,0.01,false,false,near_mint,en,USD
|
||||
Savor,BLB,Bloomburrow,109,normal,common,4,96178,1397f689-dca1-4d35-864b-92c5606afb9a,0.04,false,false,near_mint,en,USD
|
||||
Polliwallop,BLB,Bloomburrow,189,normal,common,2,95935,6bc4963c-d90b-4588-bdb7-85956e42a623,0.03,false,false,near_mint,en,USD
|
||||
Sonar Strike,BLB,Bloomburrow,32,normal,common,2,96093,a50da179-751f-47a8-a547-8c4a291ed381,0.02,false,false,near_mint,en,USD
|
||||
Uncharted Haven,FDN,Foundations,564,normal,common,3,97170,172cd5b7-98fc-4add-b858-a0b3dfb75c19,0.14,false,false,near_mint,en,USD
|
||||
Teapot Slinger,BLB,Bloomburrow,157,normal,uncommon,2,96015,30506844-349f-4b68-8cc1-d028c1611cc7,0.06,false,false,near_mint,en,USD
|
||||
Harvestrite Host,BLB,Bloomburrow,15,normal,uncommon,2,95915,41762689-0c13-4d45-9d81-ba2afad980f8,0.07,false,false,near_mint,en,USD
|
||||
Spellgyre,BLB,Bloomburrow,72,normal,uncommon,2,96139,f6f6620a-1d40-429d-9a0c-aaeb62adaa71,0.08,false,false,near_mint,en,USD
|
||||
Oakhollow Village,BLB,Bloomburrow,258,normal,uncommon,2,95624,0d49b016-b02b-459f-85e9-c04f6bdcb94e,0.35,false,false,near_mint,en,USD
|
||||
Bumbleflower's Sharepot,BLB,Bloomburrow,244,normal,common,2,95924,5f0affd5-5dcd-4dd1-a694-37a9aedf4084,0.02,false,false,near_mint,en,USD
|
||||
Overprotect,BLB,Bloomburrow,185,normal,uncommon,2,95891,079e979f-b618-4625-989c-e0ea5b61ed8a,0.55,false,false,near_mint,en,USD
|
||||
Heaped Harvest,BLB,Bloomburrow,175,normal,common,3,96255,3b5349db-0e0a-4b15-886e-0db403ef49cb,0.1,false,false,near_mint,en,USD
|
||||
Flowerfoot Swordmaster,BLB,Bloomburrow,14,normal,uncommon,2,95812,97ff118f-9c3c-43a2-8085-980c7fe7d227,0.15,false,false,near_mint,en,USD
|
||||
Banishing Light,BLB,Bloomburrow,1,normal,common,6,96011,25a06f82-ebdb-4dd6-bfe8-958018ce557c,0.04,false,false,near_mint,en,USD
|
||||
Sazacap's Brew,BLB,Bloomburrow,151,normal,common,3,96330,6d963080-b3ec-467d-82f7-39db6ecd6bbc,0.05,false,false,near_mint,en,USD
|
||||
Diresight,BLB,Bloomburrow,91,normal,common,3,95985,fada29c0-5293-40a4-b36d-d073ee99e650,0.1,false,false,near_mint,en,USD
|
||||
Gossip's Talent,BLB,Bloomburrow,51,normal,uncommon,2,95961,b299889a-03d6-4659-b0e1-f0830842e40f,0.18,false,false,near_mint,en,USD
|
||||
Fountainport Bell,BLB,Bloomburrow,245,normal,common,3,96094,a5c94bc0-a49d-451b-8e8d-64d46b8b8603,0.04,false,false,near_mint,en,USD
|
||||
Reptilian Recruiter,BLB,Bloomburrow,149,normal,uncommon,2,96072,81dec453-c9d7-42cb-980a-c82f82bede76,0.02,false,false,near_mint,en,USD
|
||||
Thistledown Players,BLB,Bloomburrow,35,normal,common,2,95960,afa8d83f-8586-4127-8b55-9715e9547488,0.01,false,false,near_mint,en,USD
|
||||
Clifftop Lookout,BLB,Bloomburrow,168,normal,uncommon,2,95931,662d3bcc-65f3-4c69-8ea1-446870a1193d,0.16,false,false,near_mint,en,USD
|
||||
Rust-Shield Rampager,BLB,Bloomburrow,190,normal,common,2,96117,c96b01f5-83de-4237-a68d-f946c53e31a6,0.02,false,false,near_mint,en,USD
|
||||
Consumed by Greed,BLB,Bloomburrow,87,normal,uncommon,2,95884,e50acc41-3517-42db-b1d3-1bdfd7294d84,0.09,false,false,near_mint,en,USD
|
||||
Rabbit Response,BLB,Bloomburrow,26,normal,common,2,96114,c4ded450-346d-4917-917a-b62bc0267509,0.02,false,false,near_mint,en,USD
|
||||
Corpseberry Cultivator,BLB,Bloomburrow,210,normal,common,2,95829,c911a759-ed7b-452b-88a3-663478357610,0.02,false,false,near_mint,en,USD
|
||||
Mind Drill Assailant,BLB,Bloomburrow,225,normal,common,2,95783,507ba708-ca9b-453e-b4c2-23b6650eb5a8,0.05,false,false,near_mint,en,USD
|
||||
Hazardroot Herbalist,BLB,Bloomburrow,174,normal,uncommon,2,96130,e2882982-b3a3-4762-a550-6b82db1038e8,0.04,false,false,near_mint,en,USD
|
||||
Dewdrop Cure,BLB,Bloomburrow,10,normal,uncommon,2,95932,666aefc2-44e0-4c27-88d5-7906f245a71f,0.13,false,false,near_mint,en,USD
|
||||
Valley Rally,BLB,Bloomburrow,159,normal,uncommon,2,95878,b6178258-1ad6-4122-a56f-6eb7d0611e84,0.04,false,false,near_mint,en,USD
|
||||
Blacksmith's Talent,BLB,Bloomburrow,125,normal,uncommon,2,96029,4bb318fa-481d-40a7-978e-f01b49101ae0,0.17,false,false,near_mint,en,USD
|
||||
Pileated Provisioner,BLB,Bloomburrow,25,normal,common,2,96102,ae442cd6-c4df-4aad-9b1d-ccd936c5ec96,0.02,false,false,near_mint,en,USD
|
||||
Short Bow,BLB,Bloomburrow,248,normal,uncommon,2,96281,51d8b72b-fa8f-48d3-bddc-d3ce9b8ba2ea,0.15,false,false,near_mint,en,USD
|
||||
Warren Elder,BLB,Bloomburrow,37,normal,common,2,96030,4bf20069-5a20-4f95-976b-6af2b69f3ad0,0.03,false,false,near_mint,en,USD
|
|
162
db/models.py
162
db/models.py
@ -1,162 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class Box(Base):
|
||||
__tablename__ = "boxes"
|
||||
|
||||
id = Column(String, primary_key=True, index=True)
|
||||
upload_id = Column(String, ForeignKey("upload_history.upload_id"))
|
||||
set_name = Column(String)
|
||||
set_code = Column(String)
|
||||
type = Column(String)
|
||||
cost = Column(Float)
|
||||
date_purchased = Column(DateTime)
|
||||
date_opened = Column(DateTime)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class ManaboxExportData(Base):
|
||||
__tablename__ = "manabox_export_data"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
upload_id = Column(String)
|
||||
box_id = Column(String, nullable=True)
|
||||
name = Column(String)
|
||||
set_code = Column(String)
|
||||
set_name = Column(String)
|
||||
collector_number = Column(String)
|
||||
foil = Column(String)
|
||||
rarity = Column(String)
|
||||
quantity = Column(Integer)
|
||||
manabox_id = Column(String)
|
||||
scryfall_id = Column(String)
|
||||
purchase_price = Column(Float)
|
||||
misprint = Column(String)
|
||||
altered = Column(String)
|
||||
condition = Column(String)
|
||||
language = Column(String)
|
||||
purchase_price_currency = Column(String)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class UploadHistory(Base):
|
||||
__tablename__ = "upload_history"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
upload_id = Column(String)
|
||||
filename = Column(String)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
status = Column(String)
|
||||
|
||||
class TCGPlayerGroups(Base):
|
||||
__tablename__ = 'tcgplayer_groups'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
group_id = Column(Integer)
|
||||
name = Column(String)
|
||||
abbreviation = Column(String)
|
||||
is_supplemental = Column(String)
|
||||
published_on = Column(String)
|
||||
modified_on = Column(String)
|
||||
category_id = Column(Integer)
|
||||
|
||||
class TCGPlayerInventory(Base):
|
||||
__tablename__ = 'tcgplayer_inventory'
|
||||
|
||||
# TCGplayer Id,Product Line,Set Name,Product Name,Title,Number,Rarity,Condition,TCG Market Price,TCG Direct Low,TCG Low Price With Shipping,TCG Low Price,Total Quantity,Add to Quantity,TCG Marketplace Price,Photo URL
|
||||
id = Column(String, primary_key=True)
|
||||
export_id = Column(String)
|
||||
tcgplayer_product_id = Column(String, ForeignKey("tcgplayer_product.id"), nullable=True)
|
||||
tcgplayer_id = Column(Integer)
|
||||
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)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class TCGPlayerExportHistory(Base):
|
||||
__tablename__ = 'tcgplayer_export_history'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
type = Column(String)
|
||||
pricing_export_id = Column(String)
|
||||
inventory_export_id = Column(String)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class TCGPlayerPricingHistory(Base):
|
||||
__tablename__ = 'tcgplayer_pricing_history'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
tcgplayer_product_id = Column(String, ForeignKey("tcgplayer_product.id"))
|
||||
export_id = Column(String)
|
||||
group_id = Column(Integer)
|
||||
tcgplayer_id = Column(Integer)
|
||||
tcg_market_price = Column(Float)
|
||||
tcg_direct_low = Column(Float)
|
||||
tcg_low_price_with_shipping = Column(Float)
|
||||
tcg_low_price = Column(Float)
|
||||
tcg_marketplace_price = Column(Float)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class TCGPlayerProduct(Base):
|
||||
__tablename__ = 'tcgplayer_product'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
group_id = Column(Integer)
|
||||
tcgplayer_id = Column(Integer)
|
||||
product_line = Column(String)
|
||||
set_name = Column(String)
|
||||
product_name = Column(String)
|
||||
title = Column(String)
|
||||
number = Column(String)
|
||||
rarity = Column(String)
|
||||
condition = Column(String)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class ManaboxTCGPlayerMapping(Base):
|
||||
__tablename__ = 'manabox_tcgplayer_mapping'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
manabox_id = Column(String, ForeignKey("manabox_export_data.id"))
|
||||
tcgplayer_id = Column(Integer, ForeignKey("tcgplayer_inventory.tcgplayer_id"))
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class SetCodeGroupIdMapping(Base):
|
||||
__tablename__ = 'set_code_group_id_mapping'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
set_code = Column(String)
|
||||
group_id = Column(Integer)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class UnmatchedManaboxData(Base):
|
||||
__tablename__ = 'unmatched_manabox_data'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
manabox_id = Column(String, ForeignKey("manabox_export_data.id"))
|
||||
reason = Column(String)
|
||||
date_created = Column(DateTime, default=datetime.now)
|
||||
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
@ -1,45 +0,0 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from services.data import DataService
|
||||
from services.upload import UploadService
|
||||
from services.box import BoxService
|
||||
from services.tcgplayer import TCGPlayerService
|
||||
from services.pricing import PricingService
|
||||
from fastapi import Depends
|
||||
from db.database import get_db
|
||||
|
||||
|
||||
## Upload
|
||||
|
||||
def get_upload_service(db: Session = Depends(get_db)) -> UploadService:
|
||||
"""Dependency injection for UploadService"""
|
||||
return UploadService(db)
|
||||
|
||||
## box
|
||||
|
||||
def get_box_service(db: Session = Depends(get_db)) -> BoxService:
|
||||
"""Dependency injection for BoxService"""
|
||||
return BoxService(db)
|
||||
|
||||
## Pricing
|
||||
|
||||
def get_pricing_service(db: Session = Depends(get_db)) -> PricingService:
|
||||
"""Dependency injection for PricingService"""
|
||||
return PricingService(db)
|
||||
|
||||
## tcgplayer
|
||||
|
||||
def get_tcgplayer_service(
|
||||
db: Session = Depends(get_db),
|
||||
pricing_service: PricingService = Depends(get_pricing_service)
|
||||
) -> TCGPlayerService:
|
||||
"""Dependency injection for TCGPlayerService"""
|
||||
return TCGPlayerService(db, pricing_service)
|
||||
|
||||
## Data
|
||||
def get_data_service(
|
||||
db: Session = Depends(get_db),
|
||||
tcgplayer_service: TCGPlayerService = Depends(get_tcgplayer_service)
|
||||
) -> DataService:
|
||||
"""Dependency injection for DataService"""
|
||||
return DataService(db, tcgplayer_service)
|
||||
|
6
dns.txt
Normal file
6
dns.txt
Normal file
@ -0,0 +1,6 @@
|
||||
@ IN MX 1 aspmx.l.google.com.
|
||||
@ IN MX 5 alt1.aspmx.l.google.com.
|
||||
@ IN MX 5 alt2.aspmx.l.google.com.
|
||||
@ IN MX 10 alt3.aspmx.l.google.com.
|
||||
@ IN MX 10 alt4.aspmx.l.google.com.
|
||||
@ IN TXT "v=spf1 include:_spf.google.com ~all"
|
75
main.py
75
main.py
@ -1,75 +0,0 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import uvicorn
|
||||
from routes.routes import router
|
||||
from db.database import init_db, check_db_connection, destroy_db, get_db
|
||||
from db.utils import db_transaction
|
||||
import logging
|
||||
import sys
|
||||
from services.tcgplayer import TCGPlayerService, PricingService
|
||||
from db.models import TCGPlayerGroups
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler('app.log') # Added this line
|
||||
]
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Create FastAPI instance
|
||||
app = FastAPI(
|
||||
title="Card Management API",
|
||||
description="API for managing card collections and TCGPlayer integration",
|
||||
version="1.0.0",
|
||||
debug=True
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Modify this in production
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(router)
|
||||
|
||||
# Optional: Add startup and shutdown events
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
# Check database connection
|
||||
if not check_db_connection():
|
||||
raise Exception("Database connection failed")
|
||||
# destroy db
|
||||
#destroy_db()
|
||||
# Initialize database
|
||||
init_db()
|
||||
# get db session
|
||||
db = next(get_db())
|
||||
# populate tcgplayer groups
|
||||
if db.query(TCGPlayerGroups).count() == 0:
|
||||
with db_transaction(db):
|
||||
tcgplayer_service = TCGPlayerService(db, PricingService(db))
|
||||
tcgplayer_service.populate_tcgplayer_groups()
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
# Clean up any connections or resources
|
||||
pass
|
||||
|
||||
# Root endpoint
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "Card Management API"}
|
||||
|
||||
# Run the application
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
25
requests.md
Normal file
25
requests.md
Normal file
@ -0,0 +1,25 @@
|
||||
curl -J http://192.168.1.41:8000/api/tcgplayer/inventory/update --remote-name
|
||||
|
||||
curl -J -X POST http://192.168.1.41:8000/api/tcgplayer/inventory/add \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"open_box_ids": ["f4a8e94c-5592-4b27-97b7-5cdb3eb45a71","81918f03-cbd2-4129-9f5c-eada6e1a811f","c2083c29-6fac-4621-b4b6-30c2dac75fab", "4ac89a6b-b8b7-44cf-8a4e-4a95c8e9006d", "0e809522-cef6-4c3c-b8a3-742c2e3c83fd","9e68466f-5abb-4725-9da8-91e5aaa4e805"]}' \
|
||||
--remote-name
|
||||
|
||||
curl -X POST http://192.168.1.41:8000/api/boxes \
|
||||
-F "type=play" \
|
||||
-F "set_code=TDM" \
|
||||
-F "sku=1234" \
|
||||
-F "num_cards_expected=420"
|
||||
|
||||
curl -X POST "http://192.168.1.41:8000/api/boxes/a77194be-8bd6-41cc-89a0-820e92ef9c04/open" \
|
||||
-F "product_id=a77194be-8bd6-41cc-89a0-820e92ef9c04" \
|
||||
-F "file_ids=b11a0292-bfdc-43de-90a8-6eb383332201" \
|
||||
-F "date_opened=2025-04-14"
|
||||
|
||||
curl -X POST "http://192.168.1.41:8000/api/processOrders" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"order_ids": ["E576ED4C-AD8CC8-9E57E","E576ED4C-2EC48E-7F185","E576ED4C-9B72C2-5E208","E576ED4C-67DAA6-B06B5","E576ED4C-7E52E0-CCE11","E576ED4C-572216-87C96","E576ED4C-37BADB-8BE45","E576ED4C-2D9E83-89C64","E576ED4C-C8080B-8066C","E576ED4C-A41099-E4F92","E576ED4C-2B5122-5E719","E576ED4C-95BB1D-07DDA","E576ED4C-1CF99A-20072","E576ED4C-342542-28CDB","E576ED4C-42720B-514DB","E576ED4C-911CB9-15174","E576ED4C-EBD55A-27AE6","E576ED4C-CC32F2-76408","E576ED4C-45328B-E65B4","E576ED4C-1F26F5-84367","E576ED4C-1D4FE5-71100","E576ED4C-BCEC53-A7CF2","E576ED4C-132791-5AF2E"]}'
|
||||
|
||||
curl -X POST "http://192.168.1.41:8000/api/processOrders" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
@ -1,27 +1,70 @@
|
||||
alembic==1.14.1
|
||||
annotated-types==0.7.0
|
||||
anyio==4.8.0
|
||||
APScheduler==3.11.0
|
||||
attrs==25.3.0
|
||||
blabel==0.1.6
|
||||
brother_ql_next==0.11.3
|
||||
Brotli==1.1.0
|
||||
browser-cookie3==0.20.1
|
||||
certifi==2025.1.31
|
||||
cffi==1.17.1
|
||||
charset-normalizer==3.4.1
|
||||
click==8.1.8
|
||||
coverage==7.6.10
|
||||
cssselect2==0.8.0
|
||||
fastapi==0.115.8
|
||||
fonttools==4.57.0
|
||||
future==1.0.0
|
||||
greenlet==3.1.1
|
||||
h11==0.14.0
|
||||
httpcore==1.0.7
|
||||
httpx==0.28.1
|
||||
idna==3.10
|
||||
iniconfig==2.0.0
|
||||
jeepney==0.9.0
|
||||
Jinja2==3.1.6
|
||||
jsons==1.6.3
|
||||
lz4==4.4.3
|
||||
Mako==1.3.9
|
||||
MarkupSafe==3.0.2
|
||||
numpy==2.2.2
|
||||
packaging==24.2
|
||||
packbits==0.6
|
||||
pandas==2.2.3
|
||||
pdf2image==1.17.0
|
||||
pillow==11.1.0
|
||||
pluggy==1.5.0
|
||||
psycopg2-binary==2.9.10
|
||||
pycparser==2.22
|
||||
pycryptodomex==3.21.0
|
||||
pydantic==2.10.6
|
||||
pydantic_core==2.27.2
|
||||
pydyf==0.11.0
|
||||
pyphen==0.17.2
|
||||
pyStrich==0.9
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.25.3
|
||||
pytest-cov==6.0.0
|
||||
python-barcode==0.15.1
|
||||
python-dateutil==2.9.0.post0
|
||||
python-multipart==0.0.20
|
||||
pytz==2025.1
|
||||
pyusb==1.3.1
|
||||
qrcode==8.1
|
||||
requests==2.32.3
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
SQLAlchemy==2.0.37
|
||||
starlette==0.45.3
|
||||
tinycss2==1.4.0
|
||||
tinyhtml5==2.0.0
|
||||
typing_extensions==4.12.2
|
||||
typish==1.9.3
|
||||
tzdata==2025.1
|
||||
tzlocal==5.2
|
||||
urllib3==2.3.0
|
||||
uvicorn==0.34.0
|
||||
weasyprint==65.0
|
||||
webencodings==0.5.1
|
||||
zopfli==0.2.3.post1
|
||||
|
167
routes/routes.py
167
routes/routes.py
@ -1,167 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request, BackgroundTasks
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Dict, Any, List
|
||||
from db.database import get_db
|
||||
from services.upload import UploadService
|
||||
from services.box import BoxService
|
||||
from services.tcgplayer import TCGPlayerService
|
||||
from services.data import DataService
|
||||
from dependencies import get_data_service, get_upload_service, get_tcgplayer_service, get_box_service
|
||||
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["cards"])
|
||||
|
||||
## health check
|
||||
@router.get("/health", response_model=dict)
|
||||
async def health_check() -> dict:
|
||||
"""
|
||||
Health check endpoint
|
||||
"""
|
||||
logger.info("Health check")
|
||||
return {"status": "ok"}
|
||||
|
||||
## test endpoint - logs all detail about request
|
||||
@router.post("/test", response_model=dict)
|
||||
async def test_endpoint(request: Request, file:UploadFile = File(...)) -> dict:
|
||||
"""
|
||||
Test endpoint
|
||||
"""
|
||||
content = await file.read()
|
||||
# log filename
|
||||
logger.info(f"file received: {file.filename}")
|
||||
# print first 100 characters of file content
|
||||
logger.info(f"file content: {content[:100]}")
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
@router.post("/upload/manabox", response_model=dict)
|
||||
async def upload_manabox(
|
||||
background_tasks: BackgroundTasks,
|
||||
upload_service: UploadService = Depends(get_upload_service),
|
||||
data_service: DataService = Depends(get_data_service),
|
||||
file: UploadFile = File(...)
|
||||
) -> dict:
|
||||
"""
|
||||
Upload endpoint for Manabox CSV files
|
||||
"""
|
||||
try:
|
||||
logger.info(f"file received: {file.filename}")
|
||||
# Read the file content
|
||||
content = await file.read()
|
||||
filename = file.filename
|
||||
if not content:
|
||||
logger.error("Empty file content")
|
||||
raise HTTPException(status_code=400, detail="Empty file content")
|
||||
|
||||
# You might want to validate it's a CSV file
|
||||
if not file.filename.endswith('.csv'):
|
||||
logger.error("File must be a CSV")
|
||||
raise HTTPException(status_code=400, detail="File must be a CSV")
|
||||
|
||||
result = upload_service.process_manabox_upload(content, filename)
|
||||
background_tasks.add_task(data_service.bg_set_manabox_tcg_relationship, upload_id=result[1])
|
||||
return result[0]
|
||||
except Exception as e:
|
||||
logger.error(f"Manabox upload failed: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
|
||||
@router.post("/createBox", response_model=dict)
|
||||
async def create_box(
|
||||
upload_id: str,
|
||||
box_service: BoxService = Depends(get_box_service)
|
||||
) -> dict:
|
||||
try:
|
||||
result = box_service.convert_upload_to_boxes(upload_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Box creation failed: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return result
|
||||
|
||||
@router.post("/deleteBox", response_model=dict)
|
||||
async def delete_box(
|
||||
box_id: str,
|
||||
box_service: BoxService = Depends(get_box_service)
|
||||
) -> dict:
|
||||
try:
|
||||
result = box_service.delete_box(box_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Box deletion failed: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@router.post("/tcgplayer/add/box/{box_id}", response_model=dict)
|
||||
async def add_box(box_id: str = None, tcgplayer_service: TCGPlayerService = Depends(get_tcgplayer_service)):
|
||||
try:
|
||||
csv_content = tcgplayer_service.add_to_tcgplayer(box_id)
|
||||
return StreamingResponse(
|
||||
iter([csv_content]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=add_to_tcgplayer.csv"}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Box add failed: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.post("/tcgplayer/update/box/{box_id}", response_model=dict)
|
||||
async def update_box(box_id: int = None):
|
||||
"""asdf"""
|
||||
pass
|
||||
|
||||
@router.post("/tcgplayer/updateInventory", response_model=dict)
|
||||
async def update_inventory(
|
||||
background_tasks: BackgroundTasks,
|
||||
tcgplayer_service: TCGPlayerService = Depends(get_tcgplayer_service),
|
||||
data_service: DataService = Depends(get_data_service)):
|
||||
try:
|
||||
result = tcgplayer_service.update_inventory('live')
|
||||
export_id = result['export_id']
|
||||
background_tasks.add_task(data_service.bg_set_tcg_inventory_product_relationship, export_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Inventory update failed: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.post("/tcgplayer/updatePricing", response_model=dict)
|
||||
async def update_inventory(
|
||||
tcgplayer_service: TCGPlayerService = Depends(get_tcgplayer_service),
|
||||
group_ids: Dict = None):
|
||||
try:
|
||||
result = tcgplayer_service.update_pricing(group_ids)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Pricing update failed: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.post("/tcgplayer/updatePricingAll", response_model=dict)
|
||||
async def update_inventory(tcgplayer_service: TCGPlayerService = Depends(get_tcgplayer_service)):
|
||||
try:
|
||||
result = tcgplayer_service.update_pricing_all()
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Pricing update failed: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.get("/tcgplayer/createLiveInventoryPricingUpdateFile")
|
||||
async def create_inventory_import(
|
||||
tcgplayer_service: TCGPlayerService = Depends(get_tcgplayer_service)
|
||||
):
|
||||
try:
|
||||
csv_content = tcgplayer_service.get_live_inventory_pricing_update_csv()
|
||||
return StreamingResponse(
|
||||
iter([csv_content]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=inventory_pricing_update.csv"}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Inventory import creation failed: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
38
send_cookie.py
Normal file
38
send_cookie.py
Normal file
@ -0,0 +1,38 @@
|
||||
import browser_cookie3
|
||||
import requests
|
||||
import json
|
||||
|
||||
def send_tcg_cookies(api_url: str, browser_type='brave'):
|
||||
"""Get TCGPlayer cookies and send them to the API"""
|
||||
try:
|
||||
# Get cookies from browser
|
||||
cookie_getter = getattr(browser_cookie3, browser_type)
|
||||
cookie_jar = cookie_getter(domain_name='tcgplayer.com')
|
||||
|
||||
# Filter essential cookies
|
||||
cookies = {}
|
||||
for cookie in cookie_jar:
|
||||
if any(key in cookie.name.lower() for key in ['.aspnet', 'tcg', 'session']):
|
||||
cookies[cookie.name] = cookie.value
|
||||
|
||||
# Send to API
|
||||
headers = {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{api_url}",
|
||||
headers=headers,
|
||||
json={'cookies': cookies}
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
print("Cookies updated successfully!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error updating cookies: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
API_URL = "http://192.168.1.41:8000/api/cookies" # Update with your API URL
|
||||
|
||||
send_tcg_cookies(API_URL)
|
100
services/box.py
100
services/box.py
@ -1,100 +0,0 @@
|
||||
from db.models import ManaboxExportData, Box, UploadHistory
|
||||
from db.utils import db_transaction
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.engine.result import Row
|
||||
|
||||
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class BoxObject:
|
||||
def __init__(
|
||||
self, upload_id: str, set_name: str,
|
||||
set_code: str, cost: float = None, date_purchased: datetime = None,
|
||||
date_opened: datetime = None, box_id: str = None):
|
||||
self.upload_id = upload_id
|
||||
self.box_id = box_id if box_id else str(uuid.uuid4())
|
||||
self.set_name = set_name
|
||||
self.set_code = set_code
|
||||
self.cost = cost
|
||||
self.date_purchased = date_purchased
|
||||
self.date_opened = date_opened
|
||||
|
||||
class BoxService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def _validate_upload_id(self, upload_id: str):
|
||||
# check if upload_history status = 'success'
|
||||
if self.db.query(UploadHistory).filter(UploadHistory.upload_id == upload_id).first() is None:
|
||||
raise Exception(f"Upload ID {upload_id} not found")
|
||||
if self.db.query(UploadHistory).filter(UploadHistory.upload_id == upload_id).first().status != 'success':
|
||||
raise Exception(f"Upload ID {upload_id} not successful")
|
||||
# check if at least 1 row in manabox_export_data with upload_id
|
||||
if self.db.query(ManaboxExportData).filter(ManaboxExportData.upload_id == upload_id).first() is None:
|
||||
raise Exception(f"Upload ID {upload_id} has no data")
|
||||
|
||||
def _get_set_info(self, upload_id: str) -> list[Row[tuple[str, str]]]:
|
||||
# get distinct set_name, set_code from manabox_export_data for upload_id
|
||||
boxes = self.db.query(ManaboxExportData.set_name, ManaboxExportData.set_code).filter(ManaboxExportData.upload_id == upload_id).distinct().all()
|
||||
if not boxes or len(boxes) == 0:
|
||||
raise Exception(f"Upload ID {upload_id} has no data")
|
||||
return boxes
|
||||
|
||||
def _update_manabox_export_data_box_id(self, box: Box):
|
||||
# based on upload_id, set_name, set_code, update box_id in manabox_export_data for all rows where box id is null
|
||||
with db_transaction(self.db):
|
||||
self.db.query(ManaboxExportData).filter(
|
||||
ManaboxExportData.upload_id == box.upload_id).filter(
|
||||
ManaboxExportData.set_name == box.set_name).filter(
|
||||
ManaboxExportData.set_code == box.set_code).filter(
|
||||
ManaboxExportData.box_id == None).update({ManaboxExportData.box_id: box.id})
|
||||
|
||||
def convert_upload_to_boxes(self, upload_id: str):
|
||||
self._validate_upload_id(upload_id)
|
||||
# get distinct set_name, set_code from manabox_export_data for upload_id
|
||||
box_set_info = self._get_set_info(upload_id)
|
||||
created_boxes = []
|
||||
# create boxes
|
||||
for box in box_set_info:
|
||||
box_obj = BoxObject(upload_id, set_name = box.set_name, set_code = box.set_code)
|
||||
new_box = self.create_box(box_obj)
|
||||
logger.info(f"Created box {new_box.id} for upload {upload_id}")
|
||||
self._update_manabox_export_data_box_id(new_box)
|
||||
created_boxes.append(new_box)
|
||||
|
||||
return {"status": "success", "boxes": f"{[box.id for box in created_boxes]}"}
|
||||
|
||||
|
||||
def create_box(self, box: BoxObject):
|
||||
with db_transaction(self.db):
|
||||
box_record = Box(
|
||||
id = box.box_id,
|
||||
upload_id = box.upload_id,
|
||||
set_name = box.set_name,
|
||||
set_code = box.set_code,
|
||||
cost = box.cost,
|
||||
date_purchased = box.date_purchased,
|
||||
date_opened = box.date_opened
|
||||
)
|
||||
self.db.add(box_record)
|
||||
return box_record
|
||||
|
||||
def get_box(self):
|
||||
pass
|
||||
|
||||
def delete_box(self, box_id: str):
|
||||
# delete box
|
||||
with db_transaction(self.db):
|
||||
self.db.query(Box).filter(Box.id == box_id).delete()
|
||||
# update manabox_export_data box_id to null
|
||||
with db_transaction(self.db):
|
||||
self.db.query(ManaboxExportData).filter(ManaboxExportData.box_id == box_id).update({ManaboxExportData.box_id: None})
|
||||
return {"status": "success", "box_id": box_id}
|
||||
|
||||
def update_box(self):
|
||||
pass
|
||||
|
149
services/data.py
149
services/data.py
@ -1,149 +0,0 @@
|
||||
from sqlalchemy.orm import Session
|
||||
import logging
|
||||
from fastapi import BackgroundTasks
|
||||
from db.models import TCGPlayerGroups, SetCodeGroupIdMapping, ManaboxExportData, TCGPlayerProduct, ManaboxTCGPlayerMapping, UnmatchedManaboxData, TCGPlayerInventory
|
||||
from db.utils import db_transaction
|
||||
import uuid
|
||||
from services.tcgplayer import TCGPlayerService
|
||||
from sqlalchemy.sql import exists
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DataService:
|
||||
def __init__(self, db: Session, tcgplayer_service: TCGPlayerService):
|
||||
self.db = db
|
||||
self.tcgplayer_service = tcgplayer_service
|
||||
|
||||
def _normalize_rarity(self, rarity: str) -> str:
|
||||
if rarity.lower() == "rare":
|
||||
return "R"
|
||||
elif rarity.lower() == "mythic":
|
||||
return "M"
|
||||
elif rarity.lower() == "uncommon":
|
||||
return "U"
|
||||
elif rarity.lower() == "common":
|
||||
return "C"
|
||||
elif rarity.lower() in ["R", "M", "U", "C"]:
|
||||
return rarity.upper()
|
||||
else:
|
||||
raise ValueError(f"Invalid rarity: {rarity}")
|
||||
|
||||
def _normalize_condition(self, condition: str, foil: str) -> str:
|
||||
if condition.lower() == "near_mint":
|
||||
condition1 = "Near Mint"
|
||||
else:
|
||||
raise ValueError(f"Invalid condition: {condition}")
|
||||
if foil.lower() == "foil":
|
||||
condition2 = " Foil"
|
||||
elif foil.lower() == "normal":
|
||||
condition2 = ""
|
||||
else:
|
||||
raise ValueError(f"Invalid foil: {foil}")
|
||||
return condition1 + condition2
|
||||
|
||||
def _normalize_number(self, number: str) -> str:
|
||||
return str(number.split(".")[0])
|
||||
|
||||
def _convert_set_code_to_group_id(self, set_code: str) -> str:
|
||||
group = self.db.query(TCGPlayerGroups).filter(TCGPlayerGroups.abbreviation == set_code).first()
|
||||
return group.group_id
|
||||
|
||||
def _add_set_group_mapping(self, set_code: str, group_id: str) -> None:
|
||||
with db_transaction(self.db):
|
||||
self.db.add(SetCodeGroupIdMapping(id=str(uuid.uuid4()), set_code=set_code, group_id=group_id))
|
||||
|
||||
def _get_set_codes(self, **filters) -> list:
|
||||
query = self.db.query(ManaboxExportData.set_code).distinct()
|
||||
for field, value in filters.items():
|
||||
if value is not None:
|
||||
query = query.filter(getattr(ManaboxExportData, field) == value)
|
||||
return [code[0] for code in query.all()]
|
||||
|
||||
async def bg_set_manabox_tcg_relationship(self, box_id: str = None, upload_id: str = None) -> None:
|
||||
if not bool(box_id) ^ bool(upload_id):
|
||||
raise ValueError("Must provide exactly one of box_id or upload_id")
|
||||
|
||||
filters = {"box_id": box_id} if box_id else {"upload_id": upload_id}
|
||||
set_codes = self._get_set_codes(**filters)
|
||||
|
||||
for set_code in set_codes:
|
||||
try:
|
||||
group_id = self._convert_set_code_to_group_id(set_code)
|
||||
except AttributeError:
|
||||
logger.warning(f"No group found for set code {set_code}")
|
||||
continue
|
||||
self._add_set_group_mapping(set_code, group_id)
|
||||
# update pricing for groups
|
||||
if self.db.query(TCGPlayerProduct).filter(TCGPlayerProduct.group_id == group_id).count() == 0:
|
||||
self.tcgplayer_service.update_pricing(set_name_ids={"set_name_ids":[group_id]})
|
||||
|
||||
# match manabox data to tcgplayer pricing data
|
||||
# match on manabox - set_code (through group_id), collector_number, foil, rarity, condition
|
||||
# match on tcgplayer - group_id, number, rarity, condition (condition + foil)
|
||||
# use normalizing functions
|
||||
matched_records = self.db.query(ManaboxExportData).filter(ManaboxExportData.set_code.in_(set_codes)).all()
|
||||
for record in matched_records:
|
||||
rarity = self._normalize_rarity(record.rarity)
|
||||
condition = self._normalize_condition(record.condition, record.foil)
|
||||
number = self._normalize_number(record.collector_number)
|
||||
group_id = self._convert_set_code_to_group_id(record.set_code)
|
||||
tcg_record = self.db.query(TCGPlayerProduct).filter(
|
||||
TCGPlayerProduct.group_id == group_id,
|
||||
TCGPlayerProduct.number == number,
|
||||
TCGPlayerProduct.rarity == rarity,
|
||||
TCGPlayerProduct.condition == condition
|
||||
).all()
|
||||
if len(tcg_record) == 0:
|
||||
logger.warning(f"No match found for {record.name}")
|
||||
if self.db.query(UnmatchedManaboxData).filter(UnmatchedManaboxData.manabox_id == record.id).count() == 0:
|
||||
with db_transaction(self.db):
|
||||
self.db.add(UnmatchedManaboxData(id=str(uuid.uuid4()), manabox_id=record.id, reason="No match found"))
|
||||
elif len(tcg_record) > 1:
|
||||
logger.warning(f"Multiple matches found for {record.name}")
|
||||
if self.db.query(UnmatchedManaboxData).filter(UnmatchedManaboxData.manabox_id == record.id).count() == 0:
|
||||
with db_transaction(self.db):
|
||||
self.db.add(UnmatchedManaboxData(id=str(uuid.uuid4()), manabox_id=record.id, reason="Multiple matches found"))
|
||||
else:
|
||||
with db_transaction(self.db):
|
||||
self.db.add(ManaboxTCGPlayerMapping(id=str(uuid.uuid4()), manabox_id=record.id, tcgplayer_id=tcg_record[0].id))
|
||||
|
||||
async def bg_set_tcg_inventory_product_relationship(self, export_id: str) -> None:
|
||||
inventory_without_product = (
|
||||
self.db.query(TCGPlayerInventory.tcgplayer_id, TCGPlayerInventory.set_name)
|
||||
.filter(TCGPlayerInventory.total_quantity > 0)
|
||||
.filter(TCGPlayerInventory.product_line == "Magic")
|
||||
.filter(TCGPlayerInventory.export_id == export_id)
|
||||
.filter(TCGPlayerInventory.tcgplayer_product_id.is_(None))
|
||||
.filter(~exists().where(
|
||||
TCGPlayerProduct.id == TCGPlayerInventory.tcgplayer_product_id
|
||||
))
|
||||
.all()
|
||||
)
|
||||
|
||||
set_names = list(set(inv.set_name for inv in inventory_without_product
|
||||
if inv.set_name is not None and isinstance(inv.set_name, str)))
|
||||
|
||||
group_ids = self.db.query(TCGPlayerGroups.group_id).filter(
|
||||
TCGPlayerGroups.name.in_(set_names)
|
||||
).all()
|
||||
|
||||
group_ids = [str(group_id[0]) for group_id in group_ids]
|
||||
|
||||
self.tcgplayer_service.update_pricing(set_name_ids={"set_name_ids": group_ids})
|
||||
|
||||
for inventory in inventory_without_product:
|
||||
product = self.db.query(TCGPlayerProduct).filter(
|
||||
TCGPlayerProduct.tcgplayer_id == inventory.tcgplayer_id
|
||||
).first()
|
||||
|
||||
if product:
|
||||
with db_transaction(self.db):
|
||||
inventory_record = self.db.query(TCGPlayerInventory).filter(
|
||||
TCGPlayerInventory.tcgplayer_id == inventory.tcgplayer_id,
|
||||
TCGPlayerInventory.export_id == export_id
|
||||
).first()
|
||||
|
||||
if inventory_record:
|
||||
inventory_record.tcgplayer_product_id = product.id
|
||||
self.db.add(inventory_record)
|
@ -1,205 +0,0 @@
|
||||
import logging
|
||||
from typing import Callable
|
||||
from db.models import TCGPlayerInventory, TCGPlayerExportHistory, TCGPlayerPricingHistory, ManaboxExportData, ManaboxTCGPlayerMapping, TCGPlayerProduct
|
||||
from sqlalchemy.orm import Session
|
||||
import pandas as pd
|
||||
from db.utils import db_transaction
|
||||
from sqlalchemy import func, and_, exists
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class PricingService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_box_with_most_recent_prices(self, box_id: str) -> pd.DataFrame:
|
||||
latest_prices = (
|
||||
self.db.query(
|
||||
TCGPlayerPricingHistory.tcgplayer_product_id,
|
||||
func.max(TCGPlayerPricingHistory.date_created).label('max_date')
|
||||
)
|
||||
.group_by(TCGPlayerPricingHistory.tcgplayer_product_id)
|
||||
.subquery('latest') # Added name to subquery
|
||||
)
|
||||
|
||||
result = (
|
||||
self.db.query(ManaboxExportData, TCGPlayerPricingHistory, TCGPlayerProduct)
|
||||
.join(ManaboxTCGPlayerMapping, ManaboxExportData.id == ManaboxTCGPlayerMapping.manabox_id)
|
||||
.join(TCGPlayerProduct, ManaboxTCGPlayerMapping.tcgplayer_id == TCGPlayerProduct.id)
|
||||
.join(TCGPlayerPricingHistory, TCGPlayerProduct.id == TCGPlayerPricingHistory.tcgplayer_product_id)
|
||||
.join(
|
||||
latest_prices,
|
||||
and_(
|
||||
TCGPlayerPricingHistory.tcgplayer_product_id == latest_prices.c.tcgplayer_product_id,
|
||||
TCGPlayerPricingHistory.date_created == latest_prices.c.max_date
|
||||
)
|
||||
)
|
||||
.filter(ManaboxExportData.box_id == box_id) # Removed str() conversion
|
||||
.all()
|
||||
)
|
||||
|
||||
logger.debug(f"Found {len(result)} rows")
|
||||
|
||||
df = pd.DataFrame([{
|
||||
**{f"manabox_{k}": v for k, v in row[0].__dict__.items() if not k.startswith('_')},
|
||||
**{f"pricing_{k}": v for k, v in row[1].__dict__.items() if not k.startswith('_')},
|
||||
**{f"tcgproduct_{k}": v for k, v in row[2].__dict__.items() if not k.startswith('_')}
|
||||
} for row in result])
|
||||
|
||||
return df
|
||||
|
||||
def get_live_inventory_with_most_recent_prices(self) -> pd.DataFrame:
|
||||
# Get latest export IDs using subqueries
|
||||
latest_inventory_export = (
|
||||
self.db.query(TCGPlayerExportHistory.inventory_export_id)
|
||||
.filter(TCGPlayerExportHistory.type == "live_inventory")
|
||||
.order_by(TCGPlayerExportHistory.date_created.desc())
|
||||
.limit(1)
|
||||
.scalar_subquery()
|
||||
)
|
||||
# this is bad because latest pricing export is not guaranteed to be related to the latest inventory export
|
||||
latest_pricing_export = (
|
||||
self.db.query(TCGPlayerExportHistory.pricing_export_id)
|
||||
.filter(TCGPlayerExportHistory.type == "pricing")
|
||||
.order_by(TCGPlayerExportHistory.date_created.desc())
|
||||
.limit(1)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
# Join inventory and pricing data in a single query
|
||||
inventory_with_pricing = (
|
||||
self.db.query(TCGPlayerInventory, TCGPlayerPricingHistory)
|
||||
.join(
|
||||
TCGPlayerPricingHistory,
|
||||
TCGPlayerInventory.tcgplayer_product_id == TCGPlayerPricingHistory.tcgplayer_product_id
|
||||
)
|
||||
.filter(
|
||||
TCGPlayerInventory.export_id == latest_inventory_export,
|
||||
TCGPlayerPricingHistory.export_id == latest_pricing_export
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Convert to pandas DataFrame
|
||||
df = pd.DataFrame([{
|
||||
# Inventory columns
|
||||
**{f"inventory_{k}": v
|
||||
for k, v in row[0].__dict__.items()
|
||||
if not k.startswith('_')},
|
||||
# Pricing columns
|
||||
**{f"pricing_{k}": v
|
||||
for k, v in row[1].__dict__.items()
|
||||
if not k.startswith('_')}
|
||||
} for row in inventory_with_pricing])
|
||||
|
||||
return df
|
||||
|
||||
def default_pricing_algo(self, df: pd.DataFrame = None):
|
||||
if df is None:
|
||||
logger.debug("No DataFrame provided, fetching live inventory with most recent prices")
|
||||
df = self.get_live_inventory_with_most_recent_prices()
|
||||
# if tcg low price is < 0.35, set my_price to 0.35
|
||||
# if either tcg low price or tcg low price with shipping is under 5, set my_price to tcg low price * 1.25
|
||||
# if tcg low price with shipping is > 25 set price to tcg low price with shipping * 1.025
|
||||
# otherwise, set price to tcg low price with shipping * 1.10
|
||||
# also round to 2 decimal places
|
||||
df['my_price'] = df.apply(lambda row: round(
|
||||
0.35 if row['pricing_tcg_low_price'] < 0.35 else
|
||||
row['pricing_tcg_low_price'] * 1.25 if row['pricing_tcg_low_price'] < 5 or row['pricing_tcg_low_price_with_shipping'] < 5 else
|
||||
row['pricing_tcg_low_price_with_shipping'] * 1.025 if row['pricing_tcg_low_price_with_shipping'] > 25 else
|
||||
row['pricing_tcg_low_price_with_shipping'] * 1.10, 2), axis=1)
|
||||
# log rows with no price
|
||||
no_price = df[df['my_price'].isnull()]
|
||||
if len(no_price) > 0:
|
||||
logger.warning(f"Found {len(no_price)} rows with no price")
|
||||
logger.warning(no_price)
|
||||
# remove rows with no price
|
||||
df = df.dropna(subset=['my_price'])
|
||||
return df
|
||||
|
||||
def convert_df_to_csv(self, df: pd.DataFrame):
|
||||
# Flip the mapping to be from current names TO desired names
|
||||
column_mapping = {
|
||||
'inventory_tcgplayer_id': 'TCGplayer Id',
|
||||
'inventory_product_line': 'Product Line',
|
||||
'inventory_set_name': 'Set Name',
|
||||
'inventory_product_name': 'Product Name',
|
||||
'inventory_title': 'Title',
|
||||
'inventory_number': 'Number',
|
||||
'inventory_rarity': 'Rarity',
|
||||
'inventory_condition': 'Condition',
|
||||
'pricing_tcg_market_price': 'TCG Market Price',
|
||||
'pricing_tcg_direct_low': 'TCG Direct Low',
|
||||
'pricing_tcg_low_price_with_shipping': 'TCG Low Price With Shipping',
|
||||
'pricing_tcg_low_price': 'TCG Low Price',
|
||||
'inventory_total_quantity': 'Total Quantity',
|
||||
'inventory_add_to_quantity': 'Add to Quantity',
|
||||
'my_price': 'TCG Marketplace Price',
|
||||
'inventory_photo_url': 'Photo URL'
|
||||
}
|
||||
|
||||
df['pricing_tcg_market_price'] = ""
|
||||
df['pricing_tcg_direct_low'] = ""
|
||||
df['pricing_tcg_low_price_with_shipping'] = ""
|
||||
df['pricing_tcg_low_price'] = ""
|
||||
df['inventory_total_quantity'] = ""
|
||||
df['inventory_add_to_quantity'] = 0
|
||||
df['inventory_photo_url'] = ""
|
||||
|
||||
# First select the columns we want (using the keys of our mapping)
|
||||
# Then rename them to the desired names (the values in our mapping)
|
||||
df = df[column_mapping.keys()].rename(columns=column_mapping)
|
||||
|
||||
return df.to_csv(index=False, quoting=1, quotechar='"')
|
||||
|
||||
def convert_add_df_to_csv(self, df: pd.DataFrame):
|
||||
column_mapping = {
|
||||
'tcgproduct_tcgplayer_id': 'TCGplayer Id',
|
||||
'tcgproduct_product_line': 'Product Line',
|
||||
'tcgproduct_set_name': 'Set Name',
|
||||
'tcgproduct_product_name': 'Product Name',
|
||||
'tcgproduct_title': 'Title',
|
||||
'tcgproduct_number': 'Number',
|
||||
'tcgproduct_rarity': 'Rarity',
|
||||
'tcgproduct_condition': 'Condition',
|
||||
'pricing_tcg_market_price': 'TCG Market Price',
|
||||
'pricing_tcg_direct_low': 'TCG Direct Low',
|
||||
'pricing_tcg_low_price_with_shipping': 'TCG Low Price With Shipping',
|
||||
'pricing_tcg_low_price': 'TCG Low Price',
|
||||
'tcgproduct_group_id': 'Total Quantity',
|
||||
'manabox_quantity': 'Add to Quantity',
|
||||
'my_price': 'TCG Marketplace Price',
|
||||
'tcgproduct_photo_url': 'Photo URL'
|
||||
}
|
||||
df['tcgproduct_group_id'] = ""
|
||||
df['pricing_tcg_market_price'] = ""
|
||||
df['pricing_tcg_direct_low'] = ""
|
||||
df['pricing_tcg_low_price_with_shipping'] = ""
|
||||
df['pricing_tcg_low_price'] = ""
|
||||
df['tcgproduct_photo_url'] = ""
|
||||
|
||||
df = df[column_mapping.keys()].rename(columns=column_mapping)
|
||||
|
||||
return df.to_csv(index=False, quoting=1, quotechar='"')
|
||||
|
||||
def create_live_inventory_pricing_update_csv(self, algo: Callable = None) -> str:
|
||||
actual_algo = algo if algo is not None else self.default_pricing_algo
|
||||
df = actual_algo()
|
||||
csv = self.convert_df_to_csv(df)
|
||||
return csv
|
||||
|
||||
def create_add_to_tcgplayer_csv(self, box_id: str = None, upload_id: str = None, algo: Callable = None) -> str:
|
||||
actual_algo = algo if algo is not None else self.default_pricing_algo
|
||||
if box_id and upload_id:
|
||||
raise ValueError("Cannot specify both box_id and upload_id")
|
||||
elif not box_id and not upload_id:
|
||||
raise ValueError("Must specify either box_id or upload_id")
|
||||
elif box_id:
|
||||
logger.debug("creating df")
|
||||
df = self.get_box_with_most_recent_prices(box_id)
|
||||
elif upload_id:
|
||||
raise NotImplementedError("Not yet implemented")
|
||||
df = actual_algo(df)
|
||||
csv = self.convert_add_df_to_csv(df)
|
||||
return csv
|
@ -1,452 +0,0 @@
|
||||
from db.models import ManaboxExportData, Box, TCGPlayerGroups, TCGPlayerInventory, TCGPlayerExportHistory, TCGPlayerPricingHistory, TCGPlayerProduct, ManaboxTCGPlayerMapping
|
||||
import requests
|
||||
from sqlalchemy.orm import Session
|
||||
from db.utils import db_transaction
|
||||
import uuid
|
||||
import browser_cookie3
|
||||
import webbrowser
|
||||
from typing import Optional, Dict ,List
|
||||
from enum import Enum
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
import urllib.parse
|
||||
import json
|
||||
from datetime import datetime
|
||||
import time
|
||||
import csv
|
||||
from typing import List, Dict, Optional
|
||||
from io import StringIO, BytesIO
|
||||
from services.pricing import PricingService
|
||||
from sqlalchemy.sql import exists
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Browser(Enum):
|
||||
"""Supported browser types for cookie extraction"""
|
||||
BRAVE = "brave"
|
||||
CHROME = "chrome"
|
||||
FIREFOX = "firefox"
|
||||
|
||||
@dataclass
|
||||
class TCGPlayerConfig:
|
||||
"""Configuration for TCGPlayer API interactions"""
|
||||
tcgplayer_base_url: str = "https://store.tcgplayer.com"
|
||||
tcgplayer_login_path: str = "/oauth/login"
|
||||
staged_inventory_download_path: str = "/Admin/Pricing/DownloadStagedInventoryExportCSV?type=Pricing"
|
||||
live_inventory_download_path = "/Admin/Pricing/DownloadMyExportCSV?type=Pricing"
|
||||
pricing_export_path: str = "/admin/pricing/downloadexportcsv"
|
||||
max_retries: int = 1
|
||||
|
||||
class TCGPlayerService:
|
||||
def __init__(self, db: Session,
|
||||
pricing_service: PricingService,
|
||||
config: TCGPlayerConfig=TCGPlayerConfig(),
|
||||
browser_type: Browser=Browser.BRAVE):
|
||||
self.db = db
|
||||
self.config = config
|
||||
self.browser_type = browser_type
|
||||
self.cookies = None
|
||||
self.previous_request_time = None
|
||||
self.pricing_service = pricing_service
|
||||
|
||||
def _insert_groups(self, groups):
|
||||
for group in groups:
|
||||
db_group = TCGPlayerGroups(
|
||||
id=str(uuid.uuid4()),
|
||||
group_id=group['groupId'],
|
||||
name=group['name'],
|
||||
abbreviation=group['abbreviation'],
|
||||
is_supplemental=group['isSupplemental'],
|
||||
published_on=group['publishedOn'],
|
||||
modified_on=group['modifiedOn'],
|
||||
category_id=group['categoryId']
|
||||
)
|
||||
self.db.add(db_group)
|
||||
|
||||
def populate_tcgplayer_groups(self):
|
||||
group_endpoint = "https://tcgcsv.com/tcgplayer/1/groups"
|
||||
response = requests.get(group_endpoint)
|
||||
response.raise_for_status()
|
||||
groups = response.json()['results']
|
||||
# manually add broken groups
|
||||
groups.append({
|
||||
"groupId": 2422,
|
||||
"name": "Modern Horizons 2 Timeshifts",
|
||||
"abbreviation": "H2R",
|
||||
"isSupplemental": "false",
|
||||
"publishedOn": "2018-11-08T00:00:00",
|
||||
"modifiedOn": "2018-11-08T00:00:00",
|
||||
"categoryId": 1
|
||||
})
|
||||
# Insert groups into db
|
||||
with db_transaction(self.db):
|
||||
self._insert_groups(groups)
|
||||
|
||||
def _get_browser_cookies(self) -> Optional[Dict]:
|
||||
"""Retrieve cookies from the specified browser"""
|
||||
try:
|
||||
cookie_getter = getattr(browser_cookie3, self.browser_type.value, None)
|
||||
if not cookie_getter:
|
||||
raise ValueError(f"Unsupported browser type: {self.browser_type.value}")
|
||||
return cookie_getter()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get browser cookies: {str(e)}")
|
||||
return None
|
||||
|
||||
def _send_request(self, url: str, method: str, data=None, except_302=False) -> requests.Response:
|
||||
"""Send a request with the specified cookies"""
|
||||
# if previous request was made less than 10 seconds ago, wait until current time is 10 seconds after previous request
|
||||
if self.previous_request_time:
|
||||
time_diff = (datetime.now() - self.previous_request_time).total_seconds()
|
||||
if time_diff < 10:
|
||||
logger.info(f"Waiting 10 seconds before next request...")
|
||||
time.sleep(10 - time_diff)
|
||||
headers = self._set_headers(method)
|
||||
|
||||
if not self.cookies:
|
||||
self.cookies = self._get_browser_cookies()
|
||||
if not self.cookies:
|
||||
raise ValueError("Failed to retrieve browser cookies")
|
||||
|
||||
try:
|
||||
#logger.info(f"debug: request url {url}, method {method}, data {data}")
|
||||
response = requests.request(method, url, headers=headers, cookies=self.cookies, data=data)
|
||||
response.raise_for_status()
|
||||
|
||||
if response.status_code == 302 and not except_302:
|
||||
logger.warning("Redirecting to login page...")
|
||||
self._refresh_authentication()
|
||||
return self._send_request(url, method, except_302=True)
|
||||
|
||||
elif response.status_code == 302 and except_302:
|
||||
raise ValueError("Redirected to login page after authentication refresh")
|
||||
|
||||
self.previous_request_time = datetime.now()
|
||||
|
||||
return response
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Request failed: {str(e)}")
|
||||
return None
|
||||
|
||||
def _set_headers(self, method: str) -> Dict:
|
||||
base_headers = {
|
||||
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
||||
'accept-language': 'en-US,en;q=0.8',
|
||||
'priority': 'u=0, i',
|
||||
'referer': 'https://store.tcgplayer.com/admin/pricing',
|
||||
'sec-ch-ua': '"Not A(Brand";v="8", "Chromium";v="132", "Brave";v="132"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"macOS"',
|
||||
'sec-fetch-dest': 'document',
|
||||
'sec-fetch-mode': 'navigate',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'sec-fetch-user': '?1',
|
||||
'sec-gpc': '1',
|
||||
'upgrade-insecure-requests': '1',
|
||||
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36'
|
||||
}
|
||||
|
||||
if method == 'POST':
|
||||
post_headers = {
|
||||
'cache-control': 'max-age=0',
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'origin': 'https://store.tcgplayer.com'
|
||||
}
|
||||
base_headers.update(post_headers)
|
||||
|
||||
return base_headers
|
||||
|
||||
def _set_pricing_export_payload(self, set_name_ids: List[str]) -> Dict:
|
||||
data = {
|
||||
"PricingType": "Pricing",
|
||||
"CategoryId": "1",
|
||||
"SetNameIds": set_name_ids,
|
||||
"ConditionIds": ["1"],
|
||||
"RarityIds": ["0"],
|
||||
"LanguageIds": ["1"],
|
||||
"PrintingIds": ["0"],
|
||||
"CompareAgainstPrice": False,
|
||||
"PriceToCompare": 3,
|
||||
"ValueToCompare": 1,
|
||||
"PriceValueToCompare": None,
|
||||
"MyInventory": False,
|
||||
"ExcludeListos": False,
|
||||
"ExportLowestListingNotMe": False
|
||||
}
|
||||
payload = "model=" + urllib.parse.quote(json.dumps(data))
|
||||
return payload
|
||||
|
||||
def _refresh_authentication(self) -> None:
|
||||
"""Open browser for user to refresh authentication"""
|
||||
login_url = f"{self.config.tcgplayer_base_url}{self.config.tcgplayer_login_path}"
|
||||
logger.info("Opening browser for authentication refresh...")
|
||||
webbrowser.open(login_url)
|
||||
input('Please login and press Enter to continue...')
|
||||
# Clear existing cookies to force refresh
|
||||
self.cookies = None
|
||||
|
||||
def _get_inventory(self, version) -> bytes:
|
||||
if version == 'staged':
|
||||
inventory_download_url = f"{self.config.tcgplayer_base_url}{self.config.staged_inventory_download_path}"
|
||||
elif version == 'live':
|
||||
inventory_download_url = f"{self.config.tcgplayer_base_url}{self.config.live_inventory_download_path}"
|
||||
else:
|
||||
raise ValueError("Invalid inventory version")
|
||||
response = self._send_request(inventory_download_url, 'GET')
|
||||
if response:
|
||||
return self._process_content(response.content)
|
||||
return None
|
||||
|
||||
def _process_content(self, content: bytes) -> List[Dict]:
|
||||
if not content:
|
||||
return []
|
||||
|
||||
try:
|
||||
text_content = content.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
for encoding in ['latin-1', 'cp1252', 'iso-8859-1']:
|
||||
try:
|
||||
text_content = content.decode(encoding)
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
else:
|
||||
raise
|
||||
|
||||
csv_file = StringIO(text_content)
|
||||
try:
|
||||
reader = csv.DictReader(csv_file)
|
||||
inventory = [
|
||||
{k: v.strip() if v else None for k, v in row.items()}
|
||||
for row in reader
|
||||
if any(v.strip() for v in row.values())
|
||||
]
|
||||
return inventory
|
||||
finally:
|
||||
csv_file.close()
|
||||
|
||||
def update_inventory(self, version: str) -> Dict:
|
||||
if version not in ['staged', 'live']:
|
||||
raise ValueError("Invalid inventory version")
|
||||
export_id = str(uuid.uuid4())
|
||||
inventory = self._get_inventory(version)
|
||||
if not inventory:
|
||||
return {"message": "No inventory to update"}
|
||||
|
||||
# add snapshot id
|
||||
for item in inventory:
|
||||
item['export_id'] = export_id
|
||||
# check if product exists for tcgplayer_id
|
||||
product_exists = self.db.query(TCGPlayerProduct).filter_by(tcgplayer_id=item['TCGplayer Id']).first()
|
||||
if product_exists:
|
||||
item['tcgplayer_product_id'] = product_exists.id
|
||||
else:
|
||||
item['tcgplayer_product_id'] = None
|
||||
|
||||
inventory_fields = {
|
||||
'TCGplayer Id': 'tcgplayer_id',
|
||||
'tcgplayer_product_id': 'tcgplayer_product_id',
|
||||
'export_id': 'export_id',
|
||||
'Product Line': 'product_line',
|
||||
'Set Name': 'set_name',
|
||||
'Product Name': 'product_name',
|
||||
'Title': 'title',
|
||||
'Number': 'number',
|
||||
'Rarity': 'rarity',
|
||||
'Condition': 'condition',
|
||||
'TCG Market Price': 'tcg_market_price',
|
||||
'TCG Direct Low': 'tcg_direct_low',
|
||||
'TCG Low Price With Shipping': 'tcg_low_price_with_shipping',
|
||||
'TCG Low Price': 'tcg_low_price',
|
||||
'Total Quantity': 'total_quantity',
|
||||
'Add to Quantity': 'add_to_quantity',
|
||||
'TCG Marketplace Price': 'tcg_marketplace_price'
|
||||
}
|
||||
|
||||
with db_transaction(self.db):
|
||||
export_history = TCGPlayerExportHistory(
|
||||
id=str(uuid.uuid4()),
|
||||
type=version + '_inventory',
|
||||
inventory_export_id=export_id
|
||||
)
|
||||
self.db.add(export_history)
|
||||
for item in inventory:
|
||||
db_item = TCGPlayerInventory(
|
||||
id=str(uuid.uuid4()),
|
||||
**{db_field: item.get(csv_field)
|
||||
for csv_field, db_field in inventory_fields.items()}
|
||||
)
|
||||
self.db.add(db_item)
|
||||
|
||||
return {"message": "Inventory updated successfully", "export_id": export_id}
|
||||
|
||||
def _get_export_csv(self, set_name_ids: List[str]) -> bytes:
|
||||
"""
|
||||
Download export CSV and save to specified path
|
||||
Returns True if successful, False otherwise
|
||||
"""
|
||||
payload = self._set_pricing_export_payload(set_name_ids)
|
||||
export_csv_download_url = f"{self.config.tcgplayer_base_url}{self.config.pricing_export_path}"
|
||||
response = self._send_request(export_csv_download_url, method='POST', data=payload)
|
||||
csv = self._process_content(response.content)
|
||||
return csv
|
||||
|
||||
def _update_tcgplayer_products(self):
|
||||
pass
|
||||
|
||||
def update_pricing(self, set_name_ids: Dict[str, List[str]]) -> Dict:
|
||||
export_id = str(uuid.uuid4())
|
||||
product_fields = {
|
||||
'TCGplayer Id': 'tcgplayer_id',
|
||||
'group_id': 'group_id',
|
||||
'Product Line': 'product_line',
|
||||
'Set Name': 'set_name',
|
||||
'Product Name': 'product_name',
|
||||
'Title': 'title',
|
||||
'Number': 'number',
|
||||
'Rarity': 'rarity',
|
||||
'Condition': 'condition'
|
||||
}
|
||||
pricing_fields = {
|
||||
'TCGplayer Id': 'tcgplayer_id',
|
||||
'tcgplayer_product_id': 'tcgplayer_product_id',
|
||||
'export_id': 'export_id',
|
||||
'group_id': 'group_id',
|
||||
'TCG Market Price': 'tcg_market_price',
|
||||
'TCG Direct Low': 'tcg_direct_low',
|
||||
'TCG Low Price With Shipping': 'tcg_low_price_with_shipping',
|
||||
'TCG Low Price': 'tcg_low_price',
|
||||
'TCG Marketplace Price': 'tcg_marketplace_price'
|
||||
}
|
||||
|
||||
for set_name_id in set_name_ids['set_name_ids']:
|
||||
export_csv = self._get_export_csv([set_name_id])
|
||||
for item in export_csv:
|
||||
item['export_id'] = export_id
|
||||
item['group_id'] = set_name_id
|
||||
# check if product already exists
|
||||
product_exists = self.db.query(TCGPlayerProduct).filter_by(tcgplayer_id=item['TCGplayer Id']).first()
|
||||
if product_exists:
|
||||
item['tcgplayer_product_id'] = product_exists.id
|
||||
else:
|
||||
with db_transaction(self.db):
|
||||
product = TCGPlayerProduct(
|
||||
id=str(uuid.uuid4()),
|
||||
**{db_field: item.get(csv_field)
|
||||
for csv_field, db_field in product_fields.items()}
|
||||
)
|
||||
self.db.add(product)
|
||||
item['tcgplayer_product_id'] = product.id
|
||||
|
||||
with db_transaction(self.db):
|
||||
ph_item = TCGPlayerPricingHistory(
|
||||
id=str(uuid.uuid4()),
|
||||
**{db_field: item.get(csv_field)
|
||||
for csv_field, db_field in pricing_fields.items()}
|
||||
)
|
||||
self.db.add(ph_item)
|
||||
|
||||
|
||||
with db_transaction(self.db):
|
||||
export_history = TCGPlayerExportHistory(
|
||||
id=str(uuid.uuid4()),
|
||||
type='pricing',
|
||||
pricing_export_id=export_id
|
||||
)
|
||||
self.db.add(export_history)
|
||||
|
||||
return {"message": "Pricing updated successfully"}
|
||||
|
||||
def update_pricing_all(self) -> Dict:
|
||||
set_name_ids = self.db.query(TCGPlayerGroups.group_id).all()
|
||||
set_name_ids = [str(group_id) for group_id, in set_name_ids]
|
||||
return self.update_pricing({'set_name_ids': set_name_ids})
|
||||
|
||||
def update_pricing_for_existing_product_groups(self) -> Dict:
|
||||
set_name_ids = self.db.query(TCGPlayerProduct.group_id).distinct().all()
|
||||
set_name_ids = [str(group_id) for group_id, in set_name_ids]
|
||||
return self.update_pricing({'set_name_ids': set_name_ids})
|
||||
|
||||
def tcg_set_tcg_inventory_product_relationship(self, export_id: str) -> None:
|
||||
inventory_without_product = (
|
||||
self.db.query(TCGPlayerInventory.tcgplayer_id, TCGPlayerInventory.set_name)
|
||||
.filter(TCGPlayerInventory.total_quantity > 0)
|
||||
.filter(TCGPlayerInventory.product_line == "Magic")
|
||||
.filter(TCGPlayerInventory.export_id == export_id)
|
||||
.filter(TCGPlayerInventory.tcgplayer_product_id.is_(None))
|
||||
.filter(~exists().where(
|
||||
TCGPlayerProduct.id == TCGPlayerInventory.tcgplayer_product_id
|
||||
))
|
||||
.all()
|
||||
)
|
||||
|
||||
set_names = list(set(inv.set_name for inv in inventory_without_product
|
||||
if inv.set_name is not None and isinstance(inv.set_name, str)))
|
||||
|
||||
group_ids = self.db.query(TCGPlayerGroups.group_id).filter(
|
||||
TCGPlayerGroups.name.in_(set_names)
|
||||
).all()
|
||||
|
||||
group_ids = [str(group_id[0]) for group_id in group_ids]
|
||||
|
||||
self.update_pricing(set_name_ids={"set_name_ids": group_ids})
|
||||
|
||||
for inventory in inventory_without_product:
|
||||
product = self.db.query(TCGPlayerProduct).filter(
|
||||
TCGPlayerProduct.tcgplayer_id == inventory.tcgplayer_id
|
||||
).first()
|
||||
|
||||
if product:
|
||||
with db_transaction(self.db):
|
||||
inventory_record = self.db.query(TCGPlayerInventory).filter(
|
||||
TCGPlayerInventory.tcgplayer_id == inventory.tcgplayer_id,
|
||||
TCGPlayerInventory.export_id == export_id
|
||||
).first()
|
||||
|
||||
if inventory_record:
|
||||
inventory_record.tcgplayer_product_id = product.id
|
||||
self.db.add(inventory_record)
|
||||
|
||||
|
||||
def get_live_inventory_pricing_update_csv(self):
|
||||
export_id = self.update_inventory("live")['export_id']
|
||||
self.tcg_set_tcg_inventory_product_relationship(export_id)
|
||||
self.update_pricing_for_existing_product_groups()
|
||||
update_csv = self.pricing_service.create_live_inventory_pricing_update_csv()
|
||||
return update_csv
|
||||
|
||||
def get_group_ids_for_box(self, box_id: str) -> List[str]:
|
||||
# use manabox_export_data.box_id and tcgplayer_product.group_id to filter
|
||||
# use manabox_tcgplayer_mapping.manabox_id and manabox_tcgplayer_mapping.tcgplayer_id to join
|
||||
group_ids = self.db.query(ManaboxExportData.box_id, TCGPlayerProduct.group_id).join(
|
||||
ManaboxTCGPlayerMapping, ManaboxExportData.id == ManaboxTCGPlayerMapping.manabox_id
|
||||
).join(
|
||||
TCGPlayerProduct, ManaboxTCGPlayerMapping.tcgplayer_id == TCGPlayerProduct.id
|
||||
).filter(ManaboxExportData.box_id == box_id).all()
|
||||
group_ids = list(set(str(group_id) for box_id, group_id in group_ids))
|
||||
return group_ids
|
||||
|
||||
def get_group_ids_for_upload(self, upload_id: str) -> List[str]:
|
||||
group_ids = self.db.query(ManaboxExportData.upload_id, TCGPlayerProduct.group_id).join(
|
||||
ManaboxTCGPlayerMapping, ManaboxExportData.id == ManaboxTCGPlayerMapping.manabox_id
|
||||
).join(
|
||||
TCGPlayerProduct, ManaboxTCGPlayerMapping.tcgplayer_id == TCGPlayerProduct.id
|
||||
).filter(ManaboxExportData.upload_id == upload_id).all()
|
||||
group_ids = list(set(str(group_id) for upload_id, group_id in group_ids))
|
||||
return group_ids
|
||||
|
||||
|
||||
def add_to_tcgplayer(self, box_id: str = None, upload_id: str = None) :
|
||||
if box_id and upload_id:
|
||||
raise ValueError("Cannot provide both box_id and upload_id")
|
||||
elif box_id:
|
||||
group_ids = self.get_group_ids_for_box(box_id)
|
||||
elif upload_id:
|
||||
group_ids = self.get_group_ids_for_upload(upload_id)
|
||||
else:
|
||||
raise ValueError("Must provide either box_id or upload_id")
|
||||
self.update_pricing({'set_name_ids': group_ids})
|
||||
add_csv = self.pricing_service.create_add_to_tcgplayer_csv(box_id)
|
||||
return add_csv
|
@ -1,97 +0,0 @@
|
||||
from db.models import ManaboxExportData, UploadHistory
|
||||
import pandas as pd
|
||||
from io import StringIO
|
||||
import uuid
|
||||
from sqlalchemy.orm import Session
|
||||
from db.utils import db_transaction
|
||||
from exceptions import FailedUploadException
|
||||
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class UploadObject:
|
||||
def __init__(self,
|
||||
content: bytes = None,
|
||||
upload_id: str = None,
|
||||
filename: str = None,
|
||||
df: pd.DataFrame = None):
|
||||
self.content = content
|
||||
self.upload_id = upload_id
|
||||
self.filename = filename
|
||||
self.df = df
|
||||
|
||||
|
||||
class UploadService:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def _content_to_df(self, content: bytes) -> pd.DataFrame:
|
||||
df = pd.read_csv(StringIO(content.decode('utf-8')))
|
||||
df.columns = df.columns.str.lower().str.replace(' ', '_')
|
||||
return df
|
||||
|
||||
def _create_upload_id(self) -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def _prepare_manabox_df(self, content: bytes, upload_id: str) -> pd.DataFrame:
|
||||
df = self._content_to_df(content)
|
||||
df['upload_id'] = upload_id
|
||||
df['box_id'] = None
|
||||
|
||||
return df
|
||||
|
||||
def _create_file_upload_record(self, upload_id: str, filename: str) -> UploadHistory:
|
||||
file_upload_record = UploadHistory(
|
||||
id = str(uuid.uuid4()),
|
||||
upload_id = upload_id,
|
||||
filename = filename,
|
||||
status = "pending"
|
||||
)
|
||||
self.db.add(file_upload_record)
|
||||
return file_upload_record
|
||||
|
||||
def _update_manabox_data(self, df: pd.DataFrame) -> bool:
|
||||
for index, row in df.iterrows():
|
||||
try:
|
||||
add_row = ManaboxExportData(
|
||||
id = str(uuid.uuid4()),
|
||||
upload_id = row['upload_id'],
|
||||
box_id = row['box_id'],
|
||||
name = row['name'],
|
||||
set_code = row['set_code'],
|
||||
set_name = row['set_name'],
|
||||
collector_number = row['collector_number'],
|
||||
foil = row['foil'],
|
||||
rarity = row['rarity'],
|
||||
quantity = row['quantity'],
|
||||
manabox_id = row['manabox_id'],
|
||||
scryfall_id = row['scryfall_id'],
|
||||
purchase_price = row['purchase_price'],
|
||||
misprint = row['misprint'],
|
||||
altered = row['altered'],
|
||||
condition = row['condition'],
|
||||
language = row['language'],
|
||||
purchase_price_currency = row['purchase_price_currency']
|
||||
)
|
||||
self.db.add(add_row)
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding row to ManaboxExportData")
|
||||
return False
|
||||
return True
|
||||
|
||||
def process_manabox_upload(self, content: bytes, filename: str):
|
||||
upload = UploadObject(content=content, filename=filename)
|
||||
upload.upload_id = self._create_upload_id()
|
||||
upload.df = self._prepare_manabox_df(upload.content, upload.upload_id)
|
||||
|
||||
with db_transaction(self.db):
|
||||
file_upload_record = self._create_file_upload_record(upload.upload_id, upload.filename)
|
||||
if not self._update_manabox_data(upload.df):
|
||||
# set upload to failed
|
||||
file_upload_record.status = "failed"
|
||||
raise FailedUploadException(file_upload_record)
|
||||
else:
|
||||
# set upload_history status to success
|
||||
file_upload_record.status = "success"
|
||||
return {"message": f"Manabox upload successful. Upload ID: {upload.upload_id}"}, upload.upload_id
|
Loading…
x
Reference in New Issue
Block a user