diff --git a/.gitignore b/.gitignore index 9bb07ea..929413d 100644 --- a/.gitignore +++ b/.gitignore @@ -169,4 +169,5 @@ cython_debug/ #.idea/ # my stuff -*.db \ No newline at end of file +*.db +temp/ \ No newline at end of file diff --git a/db/models.py b/db/models.py index 16df8a8..bef3d7d 100644 --- a/db/models.py +++ b/db/models.py @@ -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) @@ -159,4 +483,37 @@ class UnmatchedManaboxData(Base): manabox_id = Column(String, ForeignKey("manabox_export_data.id")) reason = Column(String) date_created = Column(DateTime, default=datetime.now) - date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now) \ No newline at end of file + 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" \ No newline at end of file diff --git a/dependencies.py b/dependencies.py index 539988a..92192f7 100644 --- a/dependencies.py +++ b/dependencies.py @@ -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 diff --git a/routes/routes.py b/routes/routes.py index ed3738f..aadadb3 100644 --- a/routes/routes.py +++ b/routes/routes.py @@ -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]}") +# 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)) - return {"status": "ok"} + +@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: diff --git a/schemas/__init__.py b/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/schemas/base.py b/schemas/base.py new file mode 100644 index 0000000..7870b21 --- /dev/null +++ b/schemas/base.py @@ -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 \ No newline at end of file diff --git a/schemas/box.py b/schemas/box.py new file mode 100644 index 0000000..aa9da13 --- /dev/null +++ b/schemas/box.py @@ -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") \ No newline at end of file diff --git a/schemas/file.py b/schemas/file.py new file mode 100644 index 0000000..22e199b --- /dev/null +++ b/schemas/file.py @@ -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] \ No newline at end of file diff --git a/schemas/inventory.py b/schemas/inventory.py new file mode 100644 index 0000000..61e6bcf --- /dev/null +++ b/schemas/inventory.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel, Field +from schemas.base import BaseSchema + + +class UpdateInventoryResponse(BaseModel): + success: bool = Field(..., title="Success") \ No newline at end of file diff --git a/services/box.py b/services/box.py index 5da05e9..77344d0 100644 --- a/services/box.py +++ b/services/box.py @@ -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 - - 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}) + self.inventory_service = inventory_service - 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 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_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 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 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 + + 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_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 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") - return {"status": "success", "boxes": f"{[box.id for box in created_boxes]}"} + # 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) - - 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 - \ No newline at end of file + except Exception as e: + logger.error(f"Error creating box: {str(e)}") + raise e \ No newline at end of file diff --git a/services/file.py b/services/file.py new file mode 100644 index 0000000..1791056 --- /dev/null +++ b/services/file.py @@ -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") \ No newline at end of file diff --git a/services/inventory.py b/services/inventory.py new file mode 100644 index 0000000..1bc4bd4 --- /dev/null +++ b/services/inventory.py @@ -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) \ No newline at end of file diff --git a/services/old_box.py b/services/old_box.py new file mode 100644 index 0000000..5da05e9 --- /dev/null +++ b/services/old_box.py @@ -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 + \ No newline at end of file diff --git a/services/product.py b/services/product.py new file mode 100644 index 0000000..c597966 --- /dev/null +++ b/services/product.py @@ -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 \ No newline at end of file diff --git a/services/upload.py b/services/upload.py index feb9e68..a171a9d 100644 --- a/services/upload.py +++ b/services/upload.py @@ -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"