Squashed commit of the following:

commit 893b229cc6
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Fri Feb 7 22:14:08 2025 -0500

    j

commit 06f539aea2
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Fri Feb 7 21:55:30 2025 -0500

    fk

commit d0c2960ec9
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Fri Feb 7 21:50:53 2025 -0500

    frick

commit 6b1362c166
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Fri Feb 7 21:49:40 2025 -0500

    database

commit 8cadc6df4c
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Fri Feb 7 21:38:09 2025 -0500

    asdf

commit 1ca6f98684
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Fri Feb 7 21:32:50 2025 -0500

    fffff

commit 8bb337a9c3
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Fri Feb 7 21:31:13 2025 -0500

    ffff

commit 65aba280c5
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Fri Feb 7 21:26:16 2025 -0500

    aa

commit 59ef03a59e
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Fri Feb 7 21:24:21 2025 -0500

    asdf

commit f44d5740fc
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Fri Feb 7 21:23:32 2025 -0500

    aaa

commit 13c96b1643
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Fri Feb 7 21:18:54 2025 -0500

    sdf

commit 949c795fd1
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Fri Feb 7 21:17:53 2025 -0500

    asdf

commit 8c3cd423fe
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Fri Feb 7 20:56:01 2025 -0500

    app2

commit 78eafc739e
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Fri Feb 7 20:54:55 2025 -0500

    app

commit dc47eced14
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Fri Feb 7 20:43:15 2025 -0500

    asdfasdfasdf

commit e24bcae88c
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Fri Feb 7 20:39:44 2025 -0500

    a

commit c894451bfe
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Fri Feb 7 20:38:20 2025 -0500

    req

commit 3d09869562
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Fri Feb 7 20:33:27 2025 -0500

    wrong number = code dont work lol i love computers

commit 4c93a1271b
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Fri Feb 7 20:29:39 2025 -0500

    q

commit 1f5361da88
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Fri Feb 7 18:27:20 2025 -0500

    same as original code now -5 days of my life

commit 511b070cbb
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Fri Feb 7 13:52:28 2025 -0500

    pricey worky

commit 964fdd641b
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Fri Feb 7 11:37:29 2025 -0500

    prep for pricing service work

commit a78c3bcba3
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Wed Feb 5 21:51:22 2025 -0500

    more stuff yay

commit bd9cfca7a9
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Tue Feb 4 22:30:33 2025 -0500

    GIGA FIXED EVERYTHING OMG

commit 85510a4671
Author: zman <joshua.k.rzemien@gmail.com>
Date:   Tue Feb 4 00:01:34 2025 -0500

    data model change and some new services
This commit is contained in:
2025-02-07 22:20:34 -05:00
parent 37a5dac06a
commit cc365970a9
48 changed files with 4564 additions and 1456 deletions

0
app/db/__init__.py Normal file
View File

116
app/db/database.py Normal file
View File

@@ -0,0 +1,116 @@
from sqlalchemy import create_engine, text
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
logger = logging.getLogger(__name__)
# Get database URL from environment variable with fallback
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///omegacard.db")
# Create engine with proper configuration
engine = create_engine(
DATABASE_URL,
pool_pre_ping=True, # Enable connection health checks
pool_size=5, # Set reasonable pool size
max_overflow=10 # Allow some overflow connections
)
# Create session factory
SessionLocal = sessionmaker(
bind=engine,
autocommit=False,
autoflush=False
)
@contextmanager
def get_db_session() -> Generator[Session, None, None]:
"""Context manager for database sessions"""
session = SessionLocal()
try:
yield session
except Exception as e:
logger.error(f"Database session error: {str(e)}")
session.rollback()
raise
finally:
session.close()
def get_db() -> Generator[Session, None, None]:
"""Dependency for FastAPI to get database sessions"""
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 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)
# 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
def check_db_connection() -> bool:
"""Check if database connection is working"""
try:
with get_db_session() as session:
session.execute(text("SELECT 1"))
return True
except Exception as e:
logger.error(f"Database connection check failed: {str(e)}")
return False
def destroy_db() -> None:
"""Destroy all database tables"""
from .models import Base
try:
Base.metadata.drop_all(bind=engine)
logger.info("Database tables dropped successfully")
except Exception as e:
logger.error(f"Failed to destroy database: {str(e)}")
raise

367
app/db/models.py Normal file
View File

@@ -0,0 +1,367 @@
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, ForeignKey("boxes.product_id"), 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)
# 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"

23
app/db/utils.py Normal file
View File

@@ -0,0 +1,23 @@
from contextlib import contextmanager
from sqlalchemy.orm import Session
from app.exceptions import FailedUploadException
import logging
logger = logging.getLogger(__name__)
@contextmanager
def db_transaction(db: Session):
"""Simple context manager for database transactions"""
try:
yield
db.commit()
except FailedUploadException as failed_upload:
logger.error(f"Failed upload: {str(failed_upload.message)}")
db.rollback()
db.add(failed_upload.file_upload_record)
db.commit()
raise
except Exception as e:
logger.error(f"Database error: {str(e)}")
db.rollback()
raise