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
*.db
temp/

View File

@ -1,31 +1,353 @@
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from sqlalchemy.orm import relationship, validates
from datetime import datetime
from enum import Enum
import logging
logger = logging.getLogger(__name__)
Base = declarative_base()
class Box(Base):
__tablename__ = "boxes"
## Core Models
id = Column(String, primary_key=True, index=True)
upload_id = Column(String, ForeignKey("upload_history.upload_id"))
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)
name = Column(String)
type = Column(String) # box or card
product_line = Column(String) # pokemon, mtg, etc.
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 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):
__tablename__ = "manabox_export_data"
id = Column(String, primary_key=True)
upload_id = Column(String)
box_id = Column(String, nullable=True)
# upload_id = Column(String)
# box_id = Column(String, nullable=True)
name = Column(String)
set_code = Column(String)
set_name = Column(String)
@ -50,6 +372,8 @@ class UploadHistory(Base):
id = Column(String, primary_key=True)
upload_id = Column(String)
filename = Column(String)
file_size_kb = Column(Float)
num_rows = Column(Integer)
date_created = Column(DateTime, default=datetime.now)
date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now)
status = Column(String)
@ -160,3 +484,36 @@ class UnmatchedManaboxData(Base):
reason = 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"
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.tcgplayer import TCGPlayerService
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 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
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
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"""
return BoxService(db)
return BoxService(db, inventory_service)
## Pricing

View File

@ -7,7 +7,11 @@ 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
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
@ -15,30 +19,55 @@ 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"}
# FILE
@router.post("/file/uploadManabox", response_model=FileUploadResponse)
async def upload_file(
background_tasks: BackgroundTasks,
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()
metadata.service = 'product'
result = file_service.upload_file(content, file.filename, metadata)
background_tasks.add_task(product_service.bg_process_manabox_file, result.id)
return result
except Exception as e:
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)
async def upload_manabox(
@ -55,6 +84,8 @@ async def upload_manabox(
# Read the file content
content = await file.read()
filename = file.filename
file_size = len(content)
file_size_kb = file_size / 1024
if not content:
logger.error("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")
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])
return result[0]
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
import uuid
from uuid import uuid4 as uuid
from datetime import datetime
from sqlalchemy.orm import Session
from sqlalchemy.engine.result import Row
from schemas.box import CreateOpenBoxResponse, CreateSealedBoxResponse, CreateBoxResponse
import logging
from typing import Any
from db.utils import db_transaction
from services.inventory import InventoryService
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):
def __init__(self, db: Session, inventory_service: InventoryService):
self.db = db
self.inventory_service = inventory_service
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 validate_file_ids(self, file_ids: list[str]):
# check if all file_ids are valid
for file_id in file_ids:
if self.db.query(File).filter(File.id == file_id).first() is None:
raise Exception(f"File ID {file_id} not found")
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 get_staged_product_data(self, file_ids: list[str]) -> StagedFileProduct:
staged_product_data = self.db.query(StagedFileProduct).filter(StagedFileProduct.file_id.in_(file_ids)).all()
return staged_product_data
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 aggregate_staged_product_data(self, staged_product_data: list[Row]) -> dict[Product, int]:
product_data = {}
for row in staged_product_data:
product = self.db.query(Product).filter(Product.id == row.product_id).first()
if product not in product_data:
product_data[product] = 0
product_data[product] += row.quantity
return product_data
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)
def find_product_for_box_data(self, create_box_data: dict[str, Any]) -> Product:
existing_product = self.db.query(Product).filter(
Product.name == create_box_data["name"], # TODO: needs complex enum
Product.type == "box",
Product.set_code == create_box_data["set_code"], # TODO: needs complex enum
Product.set_name == create_box_data["set_name"], # TODO: needs complex enum
Product.product_line == create_box_data["product_line"]).first()
return existing_product
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()),
name=create_box_data["name"],
type="box",
set_code=create_box_data["set_code"],
set_name=create_box_data["set_name"],
product_line=create_box_data["product_line"]
)
self.db.add(product)
return product
def create_box_db(self, product: Product, create_box_data: dict[str, Any]) -> Box:
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 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 create_open_box(self, product: Product, create_box_data: dict[str, Any]) -> OpenBox:
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 get_box(self):
pass
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 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 format_response(self, open_box: OpenBox = None, inventory: Inventory = None) -> CreateBoxResponse:
response = CreateBoxResponse(success=True)
return response
def update_box(self):
pass
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):
if not existing_product:
box_product = self.create_product_for_box(create_box_data)
box = self.create_box_db(box_product, create_box_data)
if not sealed:
open_box = self.create_open_box(box_product, create_box_data)
if file_ids:
process_staged_products = self.inventory_service.process_staged_products(product_data)
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
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(
id = str(uuid.uuid4()),
upload_id = upload_id,
filename = filename,
status = "pending"
status = "pending",
file_size_kb = file_size_kb,
num_rows = num_rows
)
self.db.add(file_upload_record)
return file_upload_record
@ -80,13 +82,14 @@ class UploadService:
return False
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.upload_id = self._create_upload_id()
upload.df = self._prepare_manabox_df(upload.content, upload.upload_id)
num_rows = len(upload.df)
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):
# set upload to failed
file_upload_record.status = "failed"