data model change and some new services

This commit is contained in:
zman 2025-02-04 00:01:34 -05:00
parent 37a5dac06a
commit 85510a4671
15 changed files with 998 additions and 130 deletions

1
.gitignore vendored
View File

@ -170,3 +170,4 @@ cython_debug/
# my stuff # my stuff
*.db *.db
temp/

View File

@ -1,31 +1,353 @@
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship, validates
from datetime import datetime from datetime import datetime
from enum import Enum
import logging
logger = logging.getLogger(__name__)
Base = declarative_base() Base = declarative_base()
class Box(Base): ## Core Models
__tablename__ = "boxes"
id = Column(String, primary_key=True, index=True) class Product(Base):
upload_id = Column(String, ForeignKey("upload_history.upload_id")) """
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)
name = Column(String)
type = Column(String) # box or card
product_line = Column(String) # pokemon, mtg, etc.
set_name = Column(String) set_name = Column(String)
set_code = 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_created = Column(DateTime, default=datetime.now)
date_modified = Column(DateTime, default=datetime.now, onupdate=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.
sku = Column(String)
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
internal: box, inventory, upload
external: price, attributes - scryfall, tcgplayer, manabox
"""
__tablename__ = "cards"
@validates("rarity")
def validate_rarity(self, key, rarity: str):
single_character_rarity = {'m': 'mythic', 'r': 'rare', 'u': 'uncommon', 'c': 'common', 'l': 'land', 'p': 'promo', 's': 'special'}
if rarity not in RarityEnum:
if rarity.lower() in RarityEnum:
rarity = rarity.lower()
elif rarity in single_character_rarity:
rarity = single_character_rarity[rarity]
else:
raise ValueError(f"Invalid rarity: {rarity}")
return rarity
@validates("condition")
def validate_condition(self, key, condition: str):
if condition not in ConditionEnum:
if condition.lower() in ConditionEnum:
condition = condition.lower()
elif condition.lower().strip().replace(' ', '_') in ConditionEnum:
condition = condition.lower().strip().replace(' ', '_')
else:
raise ValueError(f"Invalid condition: {condition}")
return condition
product_id = Column(String, ForeignKey("products.id"), primary_key=True)
number = Column(String)
foil = Column(String)
rarity = Column(String)
condition = Column(String)
language = Column(String)
scryfall_id = Column(String)
manabox_id = Column(String)
date_created = Column(DateTime, default=datetime.now)
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class CardManabox(Base):
__tablename__ = "card_manabox"
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(String)
scryfall_id = Column(String)
condition = Column(String)
language = Column(String)
class Warehouse(Base):
"""
container that is associated with a user and contains inventory and stock
"""
__tablename__ = "warehouse"
id = Column(String, primary_key=True)
user_id = Column(String, ForeignKey("users.id"), default="admin")
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__ = "stock"
product_id = Column(String, ForeignKey("products.id"), primary_key=True)
warehouse_id = Column(String, ForeignKey("warehouse.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__ = "inventory"
product_id = Column(String, ForeignKey("products.id"), primary_key=True)
warehouse_id = Column(String, ForeignKey("warehouse.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)
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)
date_created = Column(DateTime, default=datetime.now)
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class Price(Base):
__tablename__ = "price"
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"
id = Column(String, primary_key=True)
warehouse_id = Column(String, ForeignKey("warehouse.id"))
name = Column(String)
date_created = Column(DateTime, default=datetime.now)
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
## Relationships
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_block"
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 ProductMarketPrice(Base):
__tablename__ = "product_market_price"
id = Column(String, primary_key=True)
product_id = Column(String, ForeignKey("products.id"))
marketplace_id = Column(String, ForeignKey("marketplaces.id"))
price_id = Column(String, ForeignKey("price.id"))
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_card"
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_sale"
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)
## older
class ManaboxExportData(Base): class ManaboxExportData(Base):
__tablename__ = "manabox_export_data" __tablename__ = "manabox_export_data"
id = Column(String, primary_key=True) id = Column(String, primary_key=True)
upload_id = Column(String) # upload_id = Column(String)
box_id = Column(String, nullable=True) # box_id = Column(String, nullable=True)
name = Column(String) name = Column(String)
set_code = Column(String) set_code = Column(String)
set_name = Column(String) set_name = Column(String)
@ -50,6 +372,8 @@ class UploadHistory(Base):
id = Column(String, primary_key=True) id = Column(String, primary_key=True)
upload_id = Column(String) upload_id = Column(String)
filename = Column(String) filename = Column(String)
file_size_kb = Column(Float)
num_rows = Column(Integer)
date_created = Column(DateTime, default=datetime.now) date_created = Column(DateTime, default=datetime.now)
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now) date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
status = Column(String) status = Column(String)
@ -160,3 +484,36 @@ class UnmatchedManaboxData(Base):
reason = Column(String) reason = Column(String)
date_created = Column(DateTime, default=datetime.now) date_created = Column(DateTime, default=datetime.now)
date_modified = Column(DateTime, default=datetime.now, onupdate=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"
class ProductLineEnum(str, Enum):
MTG = "mtg"
POKEMON = "pokemon"
class ProductTypeEnum(str, Enum):
BOX = "box"
CARD = "card"

View File

@ -4,10 +4,39 @@ from services.upload import UploadService
from services.box import BoxService from services.box import BoxService
from services.tcgplayer import TCGPlayerService from services.tcgplayer import TCGPlayerService
from services.pricing import PricingService from services.pricing import PricingService
from fastapi import Depends from services.file import FileService
from services.product import ProductService
from services.inventory import InventoryService
from fastapi import Depends, Form
from db.database import get_db from db.database import get_db
from schemas.file import FileMetadata
## file
# file
def get_file_service(db: Session = Depends(get_db)) -> FileService:
"""Dependency injection for FileService"""
return FileService(db)
# metadata
def get_file_metadata(
type: str = Form(...),
source: str = Form(...)
) -> FileMetadata:
"""Dependency injection for FileMetadata"""
return FileMetadata(type=type, source=source)
# product
def get_product_service(db: Session = Depends(get_db), file_service: FileService = Depends(get_file_service)) -> ProductService:
"""Dependency injection for ProductService"""
return ProductService(db, file_service)
## Inventory
def get_inventory_service(db: Session = Depends(get_db)) -> InventoryService:
"""Dependency injection for InventoryService"""
return InventoryService(db)
## Upload ## Upload
def get_upload_service(db: Session = Depends(get_db)) -> UploadService: def get_upload_service(db: Session = Depends(get_db)) -> UploadService:
@ -16,9 +45,9 @@ def get_upload_service(db: Session = Depends(get_db)) -> UploadService:
## box ## box
def get_box_service(db: Session = Depends(get_db)) -> BoxService: def get_box_service(db: Session = Depends(get_db), inventory_service: InventoryService = Depends(get_inventory_service)) -> BoxService:
"""Dependency injection for BoxService""" """Dependency injection for BoxService"""
return BoxService(db) return BoxService(db, inventory_service)
## Pricing ## Pricing

View File

@ -7,7 +7,11 @@ from services.upload import UploadService
from services.box import BoxService from services.box import BoxService
from services.tcgplayer import TCGPlayerService from services.tcgplayer import TCGPlayerService
from services.data import DataService from services.data import DataService
from dependencies import get_data_service, get_upload_service, get_tcgplayer_service, get_box_service from services.file import FileService
from services.product import ProductService
from schemas.file import FileMetadata, FileUploadResponse, GetPreparedFilesResponse, FileDeleteResponse
from schemas.box import CreateBoxResponse, CreateBoxRequestData
from dependencies import get_data_service, get_upload_service, get_tcgplayer_service, get_box_service, get_file_metadata, get_file_service, get_product_service
import logging import logging
@ -15,30 +19,55 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["cards"]) 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 # FILE
@router.post("/test", response_model=dict) @router.post("/file/uploadManabox", response_model=FileUploadResponse)
async def test_endpoint(request: Request, file:UploadFile = File(...)) -> dict: async def upload_file(
""" background_tasks: BackgroundTasks,
Test endpoint file: UploadFile = File(...),
""" file_service: FileService = Depends(get_file_service),
product_service: ProductService = Depends(get_product_service),
metadata: FileMetadata = Depends(get_file_metadata)) -> FileUploadResponse:
try:
content = await file.read() content = await file.read()
# log filename metadata.service = 'product'
logger.info(f"file received: {file.filename}") result = file_service.upload_file(content, file.filename, metadata)
# print first 100 characters of file content background_tasks.add_task(product_service.bg_process_manabox_file, result.id)
logger.info(f"file content: {content[:100]}") return result
except Exception as e:
return {"status": "ok"} logger.error(f"File upload failed: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
@router.get("/file/getPreparedFiles", response_model=GetPreparedFilesResponse)
async def get_prepared_files(file_service: FileService = Depends(get_file_service)) -> GetPreparedFilesResponse:
try:
response = file_service.get_prepared_files()
return response
except Exception as e:
logger.error(f"Get prepared files failed: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
@router.post("/file/deleteFile", response_model=FileDeleteResponse)
async def delete_file(file_id: str, file_service: FileService = Depends(get_file_service)) -> FileDeleteResponse:
try:
response = file_service.delete_file(file_id)
return response
except Exception as e:
logger.error(f"Delete file failed: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
@router.post("/box/createBox", response_model=CreateBoxResponse)
async def create_box(file_ids: list[str], create_box_data: CreateBoxRequestData, box_service: BoxService = Depends(get_box_service)) -> CreateBoxResponse:
try:
create_box_data = create_box_data.dict()
response = box_service.create_box(create_box_data, file_ids)
return response
except Exception as e:
logger.error(f"Create box failed: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
## all old below
@router.post("/upload/manabox", response_model=dict) @router.post("/upload/manabox", response_model=dict)
async def upload_manabox( async def upload_manabox(
@ -55,6 +84,8 @@ async def upload_manabox(
# Read the file content # Read the file content
content = await file.read() content = await file.read()
filename = file.filename filename = file.filename
file_size = len(content)
file_size_kb = file_size / 1024
if not content: if not content:
logger.error("Empty file content") logger.error("Empty file content")
raise HTTPException(status_code=400, detail="Empty file content") raise HTTPException(status_code=400, detail="Empty file content")
@ -64,7 +95,7 @@ async def upload_manabox(
logger.error("File must be a CSV") logger.error("File must be a CSV")
raise HTTPException(status_code=400, detail="File must be a CSV") raise HTTPException(status_code=400, detail="File must be a CSV")
result = upload_service.process_manabox_upload(content, filename) result = upload_service.process_manabox_upload(content, filename, file_size_kb)
background_tasks.add_task(data_service.bg_set_manabox_tcg_relationship, upload_id=result[1]) background_tasks.add_task(data_service.bg_set_manabox_tcg_relationship, upload_id=result[1])
return result[0] return result[0]
except Exception as e: except Exception as e:

0
schemas/__init__.py Normal file
View File

13
schemas/base.py Normal file
View File

@ -0,0 +1,13 @@
from pydantic import BaseModel, Field, validator
from typing import Optional, List
from datetime import datetime
from uuid import UUID
# Base schemas with shared attributes
class BaseSchema(BaseModel):
date_created: datetime
date_modified: datetime
class Config:
from_attributes = True # Allows conversion from SQLAlchemy models

25
schemas/box.py Normal file
View File

@ -0,0 +1,25 @@
from pydantic import BaseModel, Field
from schemas.base import BaseSchema
class CreateBoxResponse(BaseModel):
success: bool = Field(..., title="Success")
class CreateOpenBoxResponse(CreateBoxResponse):
open_box_id: str
class CreateSealedBoxResponse(CreateBoxResponse):
product_id: str
quantity: int
class CreateBoxRequestData(BaseModel):
type: str = Field(..., title="Box Type (collector, play, draft)")
name: str = Field(..., title="Name")
product_line: str = Field(..., title="Product Line (MTG, Pokemon, etc)")
set_name: str = Field(..., title="Set Name")
set_code: str = Field(..., title="Set Code")
sealed: bool = Field(..., title="Sealed: Boolean")
sku: str = Field(..., title="SKU")
num_cards_expected: int = Field(..., title="Number of cards expected")
num_cards_actual: int = Field(None, title="Number of cards actual")
date_purchased: str = Field(..., title="Date purchased")
date_opened: str = Field(None, title="Date opened")

29
schemas/file.py Normal file
View File

@ -0,0 +1,29 @@
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional
from schemas.base import BaseSchema
from fastapi import UploadFile
# For additional metadata about the upload
class FileMetadata(BaseModel):
source: str = Field(..., title="Source")
type: str = Field(..., title="Type")
# optional
service: Optional[str] = Field(None, title="Service")
# For the response after upload
class FileUploadResponse(BaseSchema):
id: str
filename: str
type: str
file_size_kb: float
source: str
status: str
service: str
class FileDeleteResponse(BaseModel):
id: str
status: str
class GetPreparedFilesResponse(BaseModel):
files: list[FileUploadResponse]

6
schemas/inventory.py Normal file
View File

@ -0,0 +1,6 @@
from pydantic import BaseModel, Field
from schemas.base import BaseSchema
class UpdateInventoryResponse(BaseModel):
success: bool = Field(..., title="Success")

View File

@ -1,100 +1,135 @@
from db.models import ManaboxExportData, Box, UploadHistory from db.models import Box, File, StagedFileProduct, Product, OpenBoxCard, OpenBox, Inventory
from db.utils import db_transaction from db.utils import db_transaction
import uuid from uuid import uuid4 as uuid
from datetime import datetime from datetime import datetime
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.engine.result import Row from sqlalchemy.engine.result import Row
from schemas.box import CreateOpenBoxResponse, CreateSealedBoxResponse, CreateBoxResponse
import logging import logging
from typing import Any
from db.utils import db_transaction
from services.inventory import InventoryService
logger = logging.getLogger(__name__) 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: class BoxService:
def __init__(self, db: Session): def __init__(self, db: Session, inventory_service: InventoryService):
self.db = db self.db = db
self.inventory_service = inventory_service
def _validate_upload_id(self, upload_id: str): def validate_file_ids(self, file_ids: list[str]):
# check if upload_history status = 'success' # check if all file_ids are valid
if self.db.query(UploadHistory).filter(UploadHistory.upload_id == upload_id).first() is None: for file_id in file_ids:
raise Exception(f"Upload ID {upload_id} not found") if self.db.query(File).filter(File.id == file_id).first() is None:
if self.db.query(UploadHistory).filter(UploadHistory.upload_id == upload_id).first().status != 'success': raise Exception(f"File ID {file_id} not found")
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]]]: def get_staged_product_data(self, file_ids: list[str]) -> StagedFileProduct:
# get distinct set_name, set_code from manabox_export_data for upload_id staged_product_data = self.db.query(StagedFileProduct).filter(StagedFileProduct.file_id.in_(file_ids)).all()
boxes = self.db.query(ManaboxExportData.set_name, ManaboxExportData.set_code).filter(ManaboxExportData.upload_id == upload_id).distinct().all() return staged_product_data
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): def aggregate_staged_product_data(self, staged_product_data: list[Row]) -> dict[Product, int]:
# based on upload_id, set_name, set_code, update box_id in manabox_export_data for all rows where box id is null product_data = {}
with db_transaction(self.db): for row in staged_product_data:
self.db.query(ManaboxExportData).filter( product = self.db.query(Product).filter(Product.id == row.product_id).first()
ManaboxExportData.upload_id == box.upload_id).filter( if product not in product_data:
ManaboxExportData.set_name == box.set_name).filter( product_data[product] = 0
ManaboxExportData.set_code == box.set_code).filter( product_data[product] += row.quantity
ManaboxExportData.box_id == None).update({ManaboxExportData.box_id: box.id}) return product_data
def convert_upload_to_boxes(self, upload_id: str): def find_product_for_box_data(self, create_box_data: dict[str, Any]) -> Product:
self._validate_upload_id(upload_id) existing_product = self.db.query(Product).filter(
# get distinct set_name, set_code from manabox_export_data for upload_id Product.name == create_box_data["name"], # TODO: needs complex enum
box_set_info = self._get_set_info(upload_id) Product.type == "box",
created_boxes = [] Product.set_code == create_box_data["set_code"], # TODO: needs complex enum
# create boxes Product.set_name == create_box_data["set_name"], # TODO: needs complex enum
for box in box_set_info: Product.product_line == create_box_data["product_line"]).first()
box_obj = BoxObject(upload_id, set_name = box.set_name, set_code = box.set_code) return existing_product
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_product_for_box(self, create_box_data: dict[str, Any]) -> Product:
product = Product(
id=str(uuid()),
def create_box(self, box: BoxObject): name=create_box_data["name"],
with db_transaction(self.db): type="box",
box_record = Box( set_code=create_box_data["set_code"],
id = box.box_id, set_name=create_box_data["set_name"],
upload_id = box.upload_id, product_line=create_box_data["product_line"]
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) self.db.add(product)
return box_record return product
def get_box(self): def create_box_db(self, product: Product, create_box_data: dict[str, Any]) -> Box:
pass box = Box(
product_id=product.id,
type=create_box_data["type"],
sku=create_box_data["sku"],
num_cards_expected=create_box_data["num_cards_expected"]
)
self.db.add(box)
return box
def delete_box(self, box_id: str): def create_open_box(self, product: Product, create_box_data: dict[str, Any]) -> OpenBox:
# delete box open_box = OpenBox(
id = str(uuid()),
product_id=product.id,
num_cards_actual=create_box_data["num_cards_actual"],
date_opened=datetime.strptime(create_box_data["date_opened"], "%Y-%m-%d")
)
self.db.add(open_box)
return open_box
def add_products_to_open_box(self, open_box: OpenBox, product_data: dict[Product, int]) -> None:
for product, quantity in product_data.items():
open_box_card = OpenBoxCard(
id=str(uuid()),
open_box_id=open_box.id,
card_id=product.id,
quantity=quantity
)
self.db.add(open_box_card)
def format_response(self, open_box: OpenBox = None, inventory: Inventory = None) -> CreateBoxResponse:
response = CreateBoxResponse(success=True)
return response
def create_box(self, create_box_data: dict[str, Any], file_ids: list[str] = None) -> CreateBoxResponse:
sealed = create_box_data["sealed"]
assert isinstance(sealed, bool)
if file_ids and not sealed:
self.validate_file_ids(file_ids)
staged_product_data = self.get_staged_product_data(file_ids)
product_data = self.aggregate_staged_product_data(staged_product_data)
elif file_ids and sealed:
raise Exception("Cannot add cards with a sealed box")
# find product with all same box data
existing_product = self.find_product_for_box_data(create_box_data)
if existing_product:
box_product = existing_product
try:
with db_transaction(self.db): with db_transaction(self.db):
self.db.query(Box).filter(Box.id == box_id).delete() if not existing_product:
# update manabox_export_data box_id to null box_product = self.create_product_for_box(create_box_data)
with db_transaction(self.db): box = self.create_box_db(box_product, create_box_data)
self.db.query(ManaboxExportData).filter(ManaboxExportData.box_id == box_id).update({ManaboxExportData.box_id: None}) if not sealed:
return {"status": "success", "box_id": box_id} open_box = self.create_open_box(box_product, create_box_data)
if file_ids:
def update_box(self): process_staged_products = self.inventory_service.process_staged_products(product_data)
pass self.add_products_to_open_box(open_box, product_data)
# should be the file service handling this but im about to die irl
# update file id status to processed
for file_id in file_ids:
file = self.db.query(File).filter(File.id == file_id).first()
file.status = "processed"
self.db.add(file)
return self.format_response(open_box=open_box)
elif not file_ids and sealed:
# add sealed box to inventory
inventory = self.inventory_service.add_sealed_box_to_inventory(box_product, 1)
return self.format_response(inventory=inventory)
except Exception as e:
logger.error(f"Error creating box: {str(e)}")
raise e

75
services/file.py Normal file
View File

@ -0,0 +1,75 @@
from sqlalchemy.orm import Session
from db.utils import db_transaction
from db.models import File, StagedFileProduct
from schemas.file import FileMetadata, FileUploadResponse, GetPreparedFilesResponse, FileDeleteResponse
import os
from uuid import uuid4 as uuid
import logging
logger = logging.getLogger(__name__)
class FileService:
def __init__(self, db: Session):
self.db = db
def _format_response(self, file: File) -> FileUploadResponse:
response = FileUploadResponse(
id = file.id,
filename = file.filename,
type = file.type,
source = file.source,
status = file.status,
service = file.service,
file_size_kb = file.filesize_kb,
date_created=file.date_created,
date_modified=file.date_modified
)
return response
def upload_file(self, content: bytes, filename: str, metadata: FileMetadata) -> FileUploadResponse:
# Save file to database
with db_transaction(self.db):
file = File(
id = str(uuid()),
filename = filename,
filepath = os.getcwd() + '/temp/' + filename, # TODO: config variable
type = metadata.type,
source = metadata.source,
filesize_kb = round(len(content) / 1024,2),
status = 'pending',
service = metadata.service
)
self.db.add(file)
# save file
with open(file.filepath, 'wb') as f:
f.write(content)
response = self._format_response(file)
return response
def get_file(self, file_id: str) -> File:
return self.db.query(File).filter(File.id == file_id).first()
def get_prepared_files(self) -> list[FileUploadResponse]:
files = self.db.query(File).filter(File.status == 'prepared').all()
if len(files) == 0:
raise Exception("No prepared files found")
result = [self._format_response(file) for file in files]
logger.debug(f"Prepared files: {result}")
response = GetPreparedFilesResponse(files=result)
return response
def get_staged_products(self, file_id: str) -> list[StagedFileProduct]:
return self.db.query(StagedFileProduct).filter(StagedFileProduct.file_id == file_id).all()
def delete_file(self, file_id: str) -> FileDeleteResponse:
file = self.get_file(file_id)
staged_products = self.get_staged_products(file_id)
if file:
with db_transaction(self.db):
self.db.delete(file)
for staged_product in staged_products:
self.db.delete(staged_product)
return {"id": file_id, "status": "deleted"}
else:
raise Exception(f"File with id {file_id} not found")

28
services/inventory.py Normal file
View File

@ -0,0 +1,28 @@
from sqlalchemy.orm import Session
from db.models import Product, Inventory
from schemas.inventory import UpdateInventoryResponse
from db.utils import db_transaction
class InventoryService:
def __init__(self, db: Session):
self.db = db
def add_inventory(self, product: Product, quantity: int) -> Inventory:
inventory = self.db.query(Inventory).filter(Inventory.product_id == product.id).first()
if inventory is None:
inventory = Inventory(product_id=product.id, quantity=quantity)
self.db.add(inventory)
else:
inventory.quantity += quantity
return inventory
def process_staged_products(self, product_data: dict[Product, int]) -> UpdateInventoryResponse:
with db_transaction(self.db):
for product, quantity in product_data.items():
self.add_inventory(product, quantity)
return UpdateInventoryResponse(success=True)
def add_sealed_box_to_inventory(self, product: Product, quantity: int) -> UpdateInventoryResponse:
with db_transaction(self.db):
inventory = self.add_inventory(product, quantity)
return UpdateInventoryResponse(success=True)

100
services/old_box.py Normal file
View File

@ -0,0 +1,100 @@
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

136
services/product.py Normal file
View File

@ -0,0 +1,136 @@
from sqlalchemy.orm import Session
from db.utils import db_transaction
from db.models import Product, File, CardManabox, Card, StagedFileProduct
from io import StringIO
import pandas as pd
from services.file import FileService
from uuid import uuid4 as uuid
import logging
logger = logging.getLogger(__name__)
class ManaboxRow:
def __init__(self, row: pd.Series):
self.name = row['name']
self.set_code = row['set_code']
self.set_name = row['set_name']
self.collector_number = row['collector_number']
self.foil = row['foil']
self.rarity = row['rarity']
self.manabox_id = row['manabox_id']
self.scryfall_id = row['scryfall_id']
self.condition = row['condition']
self.language = row['language']
self.quantity = row['quantity']
class ProductService:
def __init__(self, db: Session, file_service: FileService):
self.db = db
self.file_service = file_service
def _format_manabox_df(self, df: pd.DataFrame) -> pd.DataFrame:
# format columns
df.columns = df.columns.str.lower()
df.columns = df.columns.str.replace(' ', '_')
return df
def _manabox_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_manabox_df(df)
return df
def create_staged_product(self, file: File, card_manabox:CardManabox, row: ManaboxRow) -> StagedFileProduct:
staged_product = StagedFileProduct(
id = str(uuid()),
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) -> CardManabox:
card_manabox = CardManabox(
product_id = str(uuid()),
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
)
return card_manabox
def create_product(self, card_manabox: CardManabox) -> Product:
product = Product(
id = card_manabox.product_id,
name = card_manabox.name,
set_code = card_manabox.set_code,
set_name = card_manabox.set_name,
type = 'card',
product_line = 'mtg'
)
return product
def create_card(self, card_manabox: CardManabox) -> Card:
card = Card(
product_id = card_manabox.product_id,
number = card_manabox.collector_number,
foil = card_manabox.foil,
rarity = card_manabox.rarity,
condition = card_manabox.condition,
language = card_manabox.language,
scryfall_id = card_manabox.scryfall_id,
manabox_id = card_manabox.manabox_id
)
return card
def card_manabox_lookup_create_if_not_exist(self, manabox_row: ManaboxRow) -> CardManabox:
# query based on all fields in manabox_row
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:
# create new card_manabox, card, and product
with db_transaction(self.db):
card_manabox = self.create_card_manabox(manabox_row)
product = self.create_product(card_manabox)
card = self.create_card(card_manabox)
self.db.add(card_manabox)
self.db.add(product)
self.db.add(card)
return card_manabox
def bg_process_manabox_file(self, file_id: str):
try:
file = self.file_service.get_file(file_id)
df = self._manabox_file_to_df(file)
for index, row in df.iterrows():
manabox_row = ManaboxRow(row)
card_manabox = self.card_manabox_lookup_create_if_not_exist(manabox_row)
staged_product = self.create_staged_product(file, card_manabox, row)
# update file status
with db_transaction(self.db):
file.status = 'prepared'
except Exception as e:
with db_transaction(self.db):
file.status = 'error'
raise e

View File

@ -41,12 +41,14 @@ class UploadService:
return df return df
def _create_file_upload_record(self, upload_id: str, filename: str) -> UploadHistory: def _create_file_upload_record(self, upload_id: str, filename: str, file_size_kb: float, num_rows: int) -> UploadHistory:
file_upload_record = UploadHistory( file_upload_record = UploadHistory(
id = str(uuid.uuid4()), id = str(uuid.uuid4()),
upload_id = upload_id, upload_id = upload_id,
filename = filename, filename = filename,
status = "pending" status = "pending",
file_size_kb = file_size_kb,
num_rows = num_rows
) )
self.db.add(file_upload_record) self.db.add(file_upload_record)
return file_upload_record return file_upload_record
@ -80,13 +82,14 @@ class UploadService:
return False return False
return True return True
def process_manabox_upload(self, content: bytes, filename: str): def process_manabox_upload(self, content: bytes, filename: str, file_size_kb: float) -> dict:
upload = UploadObject(content=content, filename=filename) upload = UploadObject(content=content, filename=filename)
upload.upload_id = self._create_upload_id() upload.upload_id = self._create_upload_id()
upload.df = self._prepare_manabox_df(upload.content, upload.upload_id) upload.df = self._prepare_manabox_df(upload.content, upload.upload_id)
num_rows = len(upload.df)
with db_transaction(self.db): with db_transaction(self.db):
file_upload_record = self._create_file_upload_record(upload.upload_id, upload.filename) file_upload_record = self._create_file_upload_record(upload.upload_id, upload.filename, file_size_kb, num_rows)
if not self._update_manabox_data(upload.df): if not self._update_manabox_data(upload.df):
# set upload to failed # set upload to failed
file_upload_record.status = "failed" file_upload_record.status = "failed"