This commit is contained in:
2025-02-07 20:29:39 -05:00
parent 1f5361da88
commit 4c93a1271b
37 changed files with 295 additions and 16 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 services.tcgplayer import TCGPlayerService
from services.pricing import PricingService
from services.file import FileService
from 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, ForeignKey("tcgplayer_groups.group_id"))
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 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