diff --git a/db/models.py b/db/models.py index 663718f..05fc334 100644 --- a/db/models.py +++ b/db/models.py @@ -50,12 +50,6 @@ class Sale(Base): date_created = Column(DateTime, default=datetime.now) date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now) -class Order(Base): - __tablename__ = "orders" - - id = Column(String, primary_key=True) - sale_id = Column(String, ForeignKey("sales.id")) - class Ledger(Base): """ ledger associates financial transactions with a user @@ -283,9 +277,6 @@ class StorageBlock(Base): 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 @@ -300,16 +291,6 @@ class ProductBlock(Base): 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 @@ -335,44 +316,6 @@ class ProductSale(Base): 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) - name = Column(String) - set_code = Column(String) - set_name = Column(String) - collector_number = Column(String) - foil = Column(String) - rarity = Column(String) - quantity = Column(Integer) - manabox_id = Column(String) - scryfall_id = Column(String) - purchase_price = Column(Float) - misprint = Column(String) - altered = Column(String) - condition = Column(String) - language = Column(String) - purchase_price_currency = Column(String) - date_created = Column(DateTime, default=datetime.now) - date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now) - -class UploadHistory(Base): - __tablename__ = "upload_history" - - 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) - class TCGPlayerGroups(Base): __tablename__ = 'tcgplayer_groups' @@ -385,101 +328,6 @@ class TCGPlayerGroups(Base): modified_on = Column(String) category_id = Column(Integer) -class TCGPlayerInventory(Base): - __tablename__ = 'tcgplayer_inventory' - - # TCGplayer Id,Product Line,Set Name,Product Name,Title,Number,Rarity,Condition,TCG Market Price,TCG Direct Low,TCG Low Price With Shipping,TCG Low Price,Total Quantity,Add to Quantity,TCG Marketplace Price,Photo URL - id = Column(String, primary_key=True) - export_id = Column(String) - tcgplayer_product_id = Column(String, ForeignKey("tcgplayer_product.id"), nullable=True) - tcgplayer_id = Column(Integer) - product_line = Column(String) - set_name = Column(String) - product_name = Column(String) - title = Column(String) - number = Column(String) - rarity = Column(String) - condition = Column(String) - tcg_market_price = Column(Float) - tcg_direct_low = Column(Float) - tcg_low_price_with_shipping = Column(Float) - tcg_low_price = Column(Float) - total_quantity = Column(Integer) - add_to_quantity = Column(Integer) - tcg_marketplace_price = Column(Float) - photo_url = Column(String) - date_created = Column(DateTime, default=datetime.now) - date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now) - -class TCGPlayerExportHistory(Base): - __tablename__ = 'tcgplayer_export_history' - - id = Column(String, primary_key=True) - type = Column(String) - pricing_export_id = Column(String) - inventory_export_id = Column(String) - date_created = Column(DateTime, default=datetime.now) - date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now) - -class TCGPlayerPricingHistory(Base): - __tablename__ = 'tcgplayer_pricing_history' - - id = Column(String, primary_key=True) - tcgplayer_product_id = Column(String, ForeignKey("tcgplayer_product.id")) - export_id = Column(String) - group_id = Column(Integer) - tcgplayer_id = Column(Integer) - tcg_market_price = Column(Float) - tcg_direct_low = Column(Float) - tcg_low_price_with_shipping = Column(Float) - tcg_low_price = Column(Float) - tcg_marketplace_price = Column(Float) - date_created = Column(DateTime, default=datetime.now) - date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now) - -class TCGPlayerProduct(Base): - __tablename__ = 'tcgplayer_product' - - id = Column(String, primary_key=True) - group_id = Column(Integer) - tcgplayer_id = Column(Integer) - product_line = Column(String) - set_name = Column(String) - product_name = Column(String) - title = Column(String) - number = Column(String) - rarity = Column(String) - condition = Column(String) - date_created = Column(DateTime, default=datetime.now) - date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now) - -class ManaboxTCGPlayerMapping(Base): - __tablename__ = 'manabox_tcgplayer_mapping' - - id = Column(String, primary_key=True) - manabox_id = Column(String, ForeignKey("manabox_export_data.id")) - tcgplayer_id = Column(Integer, ForeignKey("tcgplayer_inventory.tcgplayer_id")) - date_created = Column(DateTime, default=datetime.now) - date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now) - -class SetCodeGroupIdMapping(Base): - __tablename__ = 'set_code_group_id_mapping' - - id = Column(String, primary_key=True) - set_code = Column(String) - group_id = Column(Integer) - date_created = Column(DateTime, default=datetime.now) - date_modified = Column(DateTime, default=datetime.now, onupdate=datetime.now) - -class UnmatchedManaboxData(Base): - __tablename__ = 'unmatched_manabox_data' - - id = Column(String, primary_key=True) - 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) - # enums class RarityEnum(str, Enum): diff --git a/main.py b/main.py index 05dfdd2..83eb353 100644 --- a/main.py +++ b/main.py @@ -1,13 +1,10 @@ -from fastapi import FastAPI, Depends +from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware import uvicorn from routes.routes import router -from db.database import init_db, check_db_connection, destroy_db, get_db -from db.utils import db_transaction +from db.database import init_db, check_db_connection, get_db import logging import sys -from db.models import TCGPlayerGroups -from sqlalchemy.orm import Session # Import your dependency functions from dependencies import ( @@ -17,6 +14,7 @@ from dependencies import ( get_file_service, get_product_service, get_storage_service, + get_inventory_service, ) logging.basicConfig( @@ -67,10 +65,11 @@ async def startup_event(): # Use dependency injection to get services file_service = get_file_service(db) storage_service = get_storage_service(db) + inventory_service = get_inventory_service(db) tcgplayer_service = get_tcgplayer_service(db, file_service) pricing_service = get_pricing_service(db, file_service, tcgplayer_service) product_service = get_product_service(db, file_service, tcgplayer_service, storage_service) - task_service = get_task_service(db, product_service, tcgplayer_service) + task_service = get_task_service(db, product_service, pricing_service) # Start task service await task_service.start() diff --git a/routes/routes.py b/routes/routes.py index 8fee16d..79eae9e 100644 --- a/routes/routes.py +++ b/routes/routes.py @@ -1,7 +1,9 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, BackgroundTasks from fastapi.responses import StreamingResponse -from typing import Optional +from typing import Optional, List +from io import BytesIO import logging +from datetime import datetime from schemas.file import ( FileSchema, @@ -23,6 +25,7 @@ from schemas.box import ( from services.file import FileService from services.box import BoxService from services.task import TaskService +from services.pricing import PricingService from dependencies import ( get_file_service, get_box_service, @@ -30,7 +33,8 @@ from dependencies import ( get_create_file_metadata, get_box_data, get_box_update_data, - get_open_box_data + get_open_box_data, + get_pricing_service ) logger = logging.getLogger(__name__) @@ -143,7 +147,9 @@ async def create_box( ) -> CreateBoxResponse: """Create a new box.""" try: - result = box_service.create_box(box_data) + result, success = box_service.create_box(box_data) + if not success: + raise HTTPException(status_code=400, detail="Box creation failed, box already exists") return CreateBoxResponse( status_code=201, success=True, @@ -204,4 +210,69 @@ async def open_box( ) except Exception as e: logger.error(f"Open box failed: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + +@router.delete("/boxes/{box_id}/open", response_model=CreateOpenBoxResponse, status_code=200) +async def delete_open_box( + box_id: str, + box_service: BoxService = Depends(get_box_service) +) -> CreateOpenBoxResponse: + """Delete an open box by ID.""" + try: + result = box_service.delete_open_box(box_id) + return CreateOpenBoxResponse( + status_code=201, + success=True, + open_box=[OpenBoxSchema.from_orm(result)] + ) + except Exception as e: + logger.error(f"Delete open box failed: {str(e)}") + raise HTTPException(status_code=400, detail=str(e) +) + +@router.post("/tcgplayer/inventory/add", response_class=StreamingResponse) +async def create_inventory_add_file( + request: dict, # Just use a dict instead + pricing_service: PricingService = Depends(get_pricing_service), +): + """Create a new inventory add file for download.""" + try: + # Get IDs directly from the dict + open_box_ids = request.get('open_box_ids', []) + content = pricing_service.generate_tcgplayer_inventory_update_file_with_pricing(open_box_ids) + + stream = BytesIO(content) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + return StreamingResponse( + iter([stream.getvalue()]), + media_type="text/csv", + headers={ + 'Content-Disposition': f'attachment; filename="inventory_add_{timestamp}.csv"' + } + ) + except Exception as e: + logger.error(f"Create inventory add file failed: {str(e)}") + raise HTTPException(status_code=400, detail=str(e)) + +@router.get("/tcgplayer/inventory/update", response_class=StreamingResponse) +async def create_inventory_update_file( + pricing_service: PricingService = Depends(get_pricing_service), +): + """Create a new inventory update file for download.""" + try: + content = pricing_service.generate_tcgplayer_inventory_update_file_with_pricing() + + stream = BytesIO(content) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + return StreamingResponse( + iter([stream.getvalue()]), + media_type="text/csv", + headers={ + 'Content-Disposition': f'attachment; filename="inventory_update_{timestamp}.csv"' + } + ) + except Exception as e: + logger.error(f"Create inventory update file failed: {str(e)}") raise HTTPException(status_code=400, detail=str(e)) \ No newline at end of file diff --git a/schemas/base.py b/schemas/base.py index 7870b21..0dd360e 100644 --- a/schemas/base.py +++ b/schemas/base.py @@ -1,7 +1,5 @@ -from pydantic import BaseModel, Field, validator -from typing import Optional, List +from pydantic import BaseModel from datetime import datetime -from uuid import UUID # Base schemas with shared attributes diff --git a/schemas/inventory.py b/schemas/inventory.py index 61e6bcf..1cb4b98 100644 --- a/schemas/inventory.py +++ b/schemas/inventory.py @@ -1,5 +1,4 @@ from pydantic import BaseModel, Field -from schemas.base import BaseSchema class UpdateInventoryResponse(BaseModel): diff --git a/services/box.py b/services/box.py index 82111ec..5107166 100644 --- a/services/box.py +++ b/services/box.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Dict, List from uuid import uuid4 from sqlalchemy import or_ from sqlalchemy.orm import Session @@ -12,11 +12,11 @@ from db.models import ( Product, OpenBoxCard, OpenBox, - Inventory, - TCGPlayerGroups + TCGPlayerGroups, + Inventory ) from db.utils import db_transaction -from schemas.box import CreateBoxRequest, CreateBoxResponse, UpdateBoxRequest, CreateOpenBoxRequest +from schemas.box import CreateBoxRequest, UpdateBoxRequest, CreateOpenBoxRequest from services.inventory import InventoryService logger = logging.getLogger(__name__) @@ -28,15 +28,6 @@ class BoxService: self.db = db self.inventory_service = inventory_service - def validate_file_ids(self, file_ids: List[str]) -> None: - """Validate that all provided file IDs exist in the database.""" - invalid_files = [ - file_id for file_id in file_ids - if not self.db.query(File).filter(File.id == file_id).first() - ] - if invalid_files: - raise ValueError(f"File IDs not found: {', '.join(invalid_files)}") - def get_staged_product_data(self, file_ids: List[str]) -> List[StagedFileProduct]: """Retrieve staged product data for given file IDs.""" return self.db.query(StagedFileProduct).filter( @@ -52,51 +43,6 @@ class BoxService: product_data[product] = product_data.get(product, 0) + row.quantity return product_data - def find_product_for_box_data(self, create_box_data: Dict[str, Any]) -> Optional[Product]: - """Find existing product matching box data.""" - return self.db.query(Product).filter( - Product.name == create_box_data["name"], - Product.type == "box", - Product.set_code == create_box_data["set_code"], - Product.set_name == create_box_data["set_name"], - Product.product_line == create_box_data["product_line"] - ).first() - - def create_product_for_box(self, create_box_data: Dict[str, Any]) -> Product: - """Create a new product for a box.""" - product = Product( - id=str(uuid4()), - 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: - """Create a new box record in the database.""" - 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: - """Create a new open box record.""" - open_box = OpenBox( - id=str(uuid4()), - 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: """Add products to an open box.""" for product, quantity in product_data.items(): @@ -108,51 +54,6 @@ class BoxService: ) self.db.add(open_box_card) - def format_response(self, open_box: Optional[OpenBox] = None, inventory: Optional[Inventory] = None) -> CreateBoxResponse: - """Format the response for box creation.""" - return CreateBoxResponse(success=True) - - def _create_box(self, create_box_data: Dict[str, Any], file_ids: Optional[List[str]] = None) -> CreateBoxResponse: - """Internal method to handle box creation logic.""" - sealed = create_box_data["sealed"] - - if file_ids and sealed: - raise ValueError("Cannot add cards with a sealed box") - - 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) - - box_product = self.find_product_for_box_data(create_box_data) - - try: - with db_transaction(self.db): - if not box_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: - self.inventory_service.process_staged_products(product_data) - self.add_products_to_open_box(open_box, product_data) - - # Update file statuses to processed - self.db.query(File).filter(File.id.in_(file_ids)).update( - {"status": "processed"}, synchronize_session=False - ) - - return self.format_response(open_box=open_box) - elif sealed: - 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 - def validate_box_type(self, box_type: str) -> bool: """Validate if the box type is supported.""" return box_type in VALID_BOX_TYPES @@ -171,27 +72,31 @@ class BoxService: raise ValueError("Invalid set code") existing_box = self.db.query(Box).filter( - or_( - Box.type == create_box_data.type, - Box.sku == create_box_data.sku - ), - Box.set_code == create_box_data.set_code + Box.type == create_box_data.type, + Box.set_code == create_box_data.set_code, + or_(Box.sku == create_box_data.sku, Box.sku.is_(None)) ).first() if existing_box: - raise ValueError("Box already exists") + return existing_box, False + else: + with db_transaction(self.db): + product = Product( + id=str(uuid4()), + type='box', + product_line='mtg' + ) + box = Box( + product_id=product.id, + type=create_box_data.type, + set_code=create_box_data.set_code, + sku=create_box_data.sku, + num_cards_expected=create_box_data.num_cards_expected + ) + self.db.add(product) + self.db.add(box) - with db_transaction(self.db): - box = Box( - product_id=str(uuid4()), - type=create_box_data.type, - set_code=create_box_data.set_code, - sku=create_box_data.sku, - num_cards_expected=create_box_data.num_cards_expected - ) - self.db.add(box) - - return box + return box, True def update_box(self, box_id: str, update_box_data: UpdateBoxRequest) -> Box: """Update an existing box.""" @@ -221,11 +126,13 @@ class BoxService: def delete_box(self, box_id: str) -> Box: """Delete a box.""" box = self.db.query(Box).filter(Box.product_id == box_id).first() + product = self.db.query(Product).filter(Product.id == box_id).first() if not box: raise ValueError("Box not found") with db_transaction(self.db): self.db.delete(box) + self.db.delete(product) return box def open_box(self, box_id: str, box_data: CreateOpenBoxRequest) -> OpenBox: @@ -253,4 +160,43 @@ class BoxService: {"box_id": open_box.id}, synchronize_session=False ) + return open_box + + def delete_open_box(self, box_id: str) -> OpenBox: + # Fetch open box and related cards in one query + open_box = ( + self.db.query(OpenBox) + .filter(OpenBox.id == box_id) + .first() + ) + if not open_box: + raise ValueError("Open box not found") + + # Get all open box cards and related inventory items in one query + open_box_cards = ( + self.db.query(OpenBoxCard, Inventory) + .join( + Inventory, + OpenBoxCard.card_id == Inventory.product_id + ) + .filter(OpenBoxCard.open_box_id == open_box.id) + .all() + ) + + # Process inventory adjustments + for open_box_card, inventory_item in open_box_cards: + if open_box_card.quantity > inventory_item.quantity: + raise ValueError("Open box quantity exceeds inventory quantity") + + inventory_item.quantity -= open_box_card.quantity + if inventory_item.quantity == 0: + self.db.delete(inventory_item) + + # Delete the open box card + self.db.delete(open_box_card) + + # Execute all database operations in a single transaction + with db_transaction(self.db): + self.db.delete(open_box) + return open_box \ No newline at end of file diff --git a/services/pricing.py b/services/pricing.py index 7ecae0a..e4cad0b 100644 --- a/services/pricing.py +++ b/services/pricing.py @@ -5,7 +5,11 @@ from services.file import FileService from services.tcgplayer import TCGPlayerService from uuid import uuid4 from db.utils import db_transaction +from typing import List, Dict +import pandas as pd +import logging +logger = logging.getLogger(__name__) class PricingService: @@ -89,4 +93,127 @@ class PricingService: def cron_load_prices(self, file: File = None): file_content = self.get_pricing_export_content(file) - self.load_pricing_csv_content_to_db(file_content) \ No newline at end of file + self.load_pricing_csv_content_to_db(file_content) + + def get_all_prices_for_products(self, product_ids: List[str]) -> Dict[str, Dict[str, float]]: + all_prices = self.db.query(Price).filter( + Price.product_id.in_(product_ids) + ).all() + + price_lookup = {} + for price in all_prices: + if price.product_id not in price_lookup: + price_lookup[price.product_id] = {} + price_lookup[price.product_id][price.type] = price.price + return price_lookup + + def apply_price_to_df_columns(self, row: pd.Series, price_lookup: Dict[str, Dict[str, float]]) -> pd.Series: + product_prices = price_lookup.get(row['product_id'], {}) + for price_type, price in product_prices.items(): + row[price_type] = price + return row + + def default_pricing_algo(self, row: pd.Series) -> pd.Series: + """Default pricing algorithm with complex pricing rules""" + tcg_low = row.get('tcg_low_price') + tcg_low_shipping = row.get('tcg_low_price_with_shipping') + + if pd.isna(tcg_low) or pd.isna(tcg_low_shipping): + logger.warning(f"Missing pricing data for row: {row}") + row['new_price'] = None + return row + + # Apply pricing rules + if tcg_low < 0.35: + new_price = 0.35 + elif tcg_low < 5 or tcg_low_shipping < 5: + new_price = round(tcg_low * 1.25, 2) + elif tcg_low_shipping > 25: + new_price = round(tcg_low_shipping * 1.025, 2) + else: + new_price = round(tcg_low_shipping * 1.10, 2) + + row['new_price'] = new_price + return row + + def apply_pricing_algo(self, row: pd.Series, pricing_algo: callable = None) -> pd.Series: + """Modified to handle the pricing algorithm as an instance method""" + if pricing_algo is None: + pricing_algo = self.default_pricing_algo + return pricing_algo(row) + + def generate_tcgplayer_inventory_update_file_with_pricing(self, open_box_ids: List[str] = None) -> bytes: + desired_columns = [ + 'TCGplayer Id', 'Product Line', 'Set Name', 'Product Name', + 'Title', 'Number', 'Rarity', 'Condition', 'TCG Market Price', + 'TCG Direct Low', 'TCG Low Price With Shipping', 'TCG Low Price', + 'Total Quantity', 'Add to Quantity', 'TCG Marketplace Price', 'Photo URL' + ] + + if open_box_ids: + # Get initial dataframe + update_type = 'add' + df = self.tcgplayer_service.open_box_cards_to_tcgplayer_inventory_df(open_box_ids) + else: + update_type = 'update' + df = self.tcgplayer_service.get_inventory_df('live') + # remove rows with total quantity of 0 + df = df[df['total_quantity'] != 0] + tcgplayer_ids = df['tcgplayer_id'].unique().tolist() + + # Make a single query to get all matching records + product_id_mapping = { + card.tcgplayer_id: card.product_id + for card in self.db.query(CardTCGPlayer) + .filter(CardTCGPlayer.tcgplayer_id.in_(tcgplayer_ids)) + .all() + } + + # Map the ids using the dictionary + df['product_id'] = df['tcgplayer_id'].map(product_id_mapping) + + price_lookup = self.get_all_prices_for_products(df['product_id'].unique()) + + # Apply price columns + df = df.apply(lambda row: self.apply_price_to_df_columns(row, price_lookup), axis=1) + + # Apply pricing algorithm + df = df.apply(self.apply_pricing_algo, axis=1) + + # if update type is update, remove rows where new_price == listed_price + if update_type == 'update': + df = df[df['new_price'] != df['listed_price']] + + # Set marketplace price + df['TCG Marketplace Price'] = df['new_price'] + + column_mapping = { + 'tcgplayer_id': 'TCGplayer Id', + 'product_line': 'Product Line', + 'set_name': 'Set Name', + 'product_name': 'Product Name', + 'title': 'Title', + 'number': 'Number', + 'rarity': 'Rarity', + 'condition': 'Condition', + 'tcg_market_price': 'TCG Market Price', + 'tcg_direct_low': 'TCG Direct Low', + 'tcg_low_price_with_shipping': 'TCG Low Price With Shipping', + 'tcg_low_price': 'TCG Low Price', + 'total_quantity': 'Total Quantity', + 'add_to_quantity': 'Add to Quantity', + 'photo_url': 'Photo URL' + } + df = df.rename(columns=column_mapping) + + # Now do your column selection + df = df[desired_columns] + + # remove any rows with no price + #df = df[df['TCG Marketplace Price'] != 0] + #df = df[df['TCG Marketplace Price'].notna()] + + # Convert to CSV bytes + csv_bytes = self.df_util.df_to_csv_bytes(df) + + return csv_bytes \ No newline at end of file diff --git a/services/storage.py b/services/storage.py index 7a7881f..3a4463e 100644 --- a/services/storage.py +++ b/services/storage.py @@ -1,5 +1,5 @@ from uuid import uuid4 -from typing import List, TypedDict, Optional +from typing import List, TypedDict from sqlalchemy.orm import Session from db.utils import db_transaction diff --git a/services/task.py b/services/task.py index 4db4f12..b265a19 100644 --- a/services/task.py +++ b/services/task.py @@ -14,12 +14,13 @@ class TaskService: self.tasks: Dict[str, Callable] = {} self.db = db self.product_service = product_service - self.tcgplayer_service = pricing_service + self.pricing_service = pricing_service async def start(self): self.scheduler.start() self.logger.info("Task scheduler started.") self.register_scheduled_tasks() + # self.pricing_service.generate_tcgplayer_inventory_update_file_with_pricing(['e20cc342-23cb-4593-89cb-56a0cb3ed3f3']) def register_scheduled_tasks(self): self.scheduler.add_job(self.hourly_pricing, 'cron', minute='0') diff --git a/services/tcgplayer.py b/services/tcgplayer.py index f5ddcb8..4e74b23 100644 --- a/services/tcgplayer.py +++ b/services/tcgplayer.py @@ -1,7 +1,8 @@ -from db.models import ManaboxExportData, Box, TCGPlayerGroups, TCGPlayerInventory, TCGPlayerExportHistory, TCGPlayerPricingHistory, TCGPlayerProduct, ManaboxTCGPlayerMapping, CardManabox, CardTCGPlayer, Price, Product, Card, File +from db.models import TCGPlayerGroups, CardTCGPlayer, Product, Card, File, Inventory, OpenBox, OpenBoxCard import requests from services.util._dataframe import TCGPlayerPricingRow, DataframeUtil, ManaboxRow from services.file import FileService +from services.inventory import InventoryService from sqlalchemy.orm import Session from db.utils import db_transaction from uuid import uuid4 as uuid @@ -15,10 +16,7 @@ import urllib.parse import json from datetime import datetime import time -import csv from typing import List, Dict, Optional -from io import StringIO, BytesIO -from sqlalchemy.sql import exists import pandas as pd from sqlalchemy.exc import SQLAlchemyError from schemas.file import CreateFileRequest @@ -204,7 +202,7 @@ class TCGPlayerService: # Clear existing cookies to force refresh self.cookies = None - def _get_inventory(self, version) -> bytes: + def get_inventory_df(self, version: str) -> pd.DataFrame: if version == 'staged': inventory_download_url = f"{self.config.tcgplayer_base_url}{self.config.staged_inventory_download_path}" elif version == 'live': @@ -212,94 +210,10 @@ class TCGPlayerService: else: raise ValueError("Invalid inventory version") response = self._send_request(inventory_download_url, 'GET') - if response: - return self._process_content(response.content) - return None + df = self.df_util.csv_bytes_to_df(response.content) + return df - def _process_content(self, content: bytes) -> List[Dict]: - if not content: - return [] - - try: - text_content = content.decode('utf-8') - except UnicodeDecodeError: - for encoding in ['latin-1', 'cp1252', 'iso-8859-1']: - try: - text_content = content.decode(encoding) - break - except UnicodeDecodeError: - continue - else: - raise - - csv_file = StringIO(text_content) - try: - reader = csv.DictReader(csv_file) - inventory = [ - {k: v.strip() if v else None for k, v in row.items()} - for row in reader - if any(v.strip() for v in row.values()) - ] - return inventory - finally: - csv_file.close() - - def update_inventory(self, version: str) -> Dict: - if version not in ['staged', 'live']: - raise ValueError("Invalid inventory version") - export_id = str(uuid()) - inventory = self._get_inventory(version) - if not inventory: - return {"message": "No inventory to update"} - - # add snapshot id - for item in inventory: - item['export_id'] = export_id - # check if product exists for tcgplayer_id - product_exists = self.db.query(TCGPlayerProduct).filter_by(tcgplayer_id=item['TCGplayer Id']).first() - if product_exists: - item['tcgplayer_product_id'] = product_exists.id - else: - item['tcgplayer_product_id'] = None - - inventory_fields = { - 'TCGplayer Id': 'tcgplayer_id', - 'tcgplayer_product_id': 'tcgplayer_product_id', - 'export_id': 'export_id', - 'Product Line': 'product_line', - 'Set Name': 'set_name', - 'Product Name': 'product_name', - 'Title': 'title', - 'Number': 'number', - 'Rarity': 'rarity', - 'Condition': 'condition', - 'TCG Market Price': 'tcg_market_price', - 'TCG Direct Low': 'tcg_direct_low', - 'TCG Low Price With Shipping': 'tcg_low_price_with_shipping', - 'TCG Low Price': 'tcg_low_price', - 'Total Quantity': 'total_quantity', - 'Add to Quantity': 'add_to_quantity', - 'TCG Marketplace Price': 'tcg_marketplace_price' - } - - with db_transaction(self.db): - export_history = TCGPlayerExportHistory( - id=str(uuid()), - type=version + '_inventory', - inventory_export_id=export_id - ) - self.db.add(export_history) - for item in inventory: - db_item = TCGPlayerInventory( - id=str(uuid()), - **{db_field: item.get(csv_field) - for csv_field, db_field in inventory_fields.items()} - ) - self.db.add(db_item) - - return {"message": "Inventory updated successfully", "export_id": export_id} - - def _get_export_csv(self, set_name_ids: List[str], convert=True) -> bytes: + def _get_export_csv(self, set_name_ids: List[str]) -> bytes: """ Download export CSV and save to specified path Returns True if successful, False otherwise @@ -308,11 +222,7 @@ class TCGPlayerService: payload = self._set_pricing_export_payload(set_name_ids) export_csv_download_url = f"{self.config.tcgplayer_base_url}{self.config.pricing_export_path}" response = self._send_request(export_csv_download_url, method='POST', data=payload) - if convert: - csv = self._process_content(response.content) - return csv - else: - return response.content + return response.content def create_tcgplayer_card(self, row: TCGPlayerPricingRow, group_id: int): # if card already exists, return none @@ -558,7 +468,7 @@ class TCGPlayerService: try: all_group_ids = self.db.query(TCGPlayerGroups.group_id).all() all_group_ids = [str(group_id) for group_id, in all_group_ids] - export_csv = self._get_export_csv(all_group_ids, convert=False) + export_csv = self._get_export_csv(all_group_ids) export_csv_file = self.file_service.create_file(export_csv, CreateFileRequest( source="tcgplayer", type="tcgplayer_pricing_export", @@ -581,4 +491,43 @@ class TCGPlayerService: except Exception as e: logger.error(f"Failed to load prices: {e}") - raise \ No newline at end of file + raise + + def open_box_cards_to_tcgplayer_inventory_df(self, open_box_ids: List[str]) -> pd.DataFrame: + tcgcards = (self.db.query(OpenBoxCard, CardTCGPlayer) + .filter(OpenBoxCard.open_box_id.in_(open_box_ids)) + .join(CardTCGPlayer, OpenBoxCard.card_id == CardTCGPlayer.product_id) + .all()) + + if not tcgcards: + return None + + # Create dataframe + df = pd.DataFrame([(tcg.product_id, tcg.tcgplayer_id, tcg.product_line, tcg.set_name, tcg.product_name, + tcg.title, tcg.number, tcg.rarity, tcg.condition, obc.quantity) + for obc, tcg in tcgcards], + columns=['product_id', 'tcgplayer_id', 'product_line', 'set_name', 'product_name', + 'title', 'number', 'rarity', 'condition', 'quantity']) + + # Add empty columns + df['Total Quantity'] = '' + df['Add to Quantity'] = df['quantity'] + df['TCG Marketplace Price'] = '' + df['Photo URL'] = '' + + # Rename columns + df = df.rename(columns={ + 'tcgplayer_id': 'TCGplayer Id', + 'product_line': 'Product Line', + 'set_name': 'Set Name', + 'product_name': 'Product Name', + 'title': 'Title', + 'number': 'Number', + 'rarity': 'Rarity', + 'condition': 'Condition' + }) + + return df + + + diff --git a/services/util/_dataframe.py b/services/util/_dataframe.py index 9786b27..bf603b6 100644 --- a/services/util/_dataframe.py +++ b/services/util/_dataframe.py @@ -65,4 +65,8 @@ class DataframeUtil: content = content.decode('utf-8') df = pd.read_csv(StringIO(content)) df = self.format_df_columns(df) - return df \ No newline at end of file + return df + + def df_to_csv_bytes(self, df: pd.DataFrame) -> bytes: + csv = df.to_csv(index=False) + return csv.encode('utf-8') \ No newline at end of file diff --git a/tests/box_test.py b/tests/box_test.py index 442e116..aa03c75 100644 --- a/tests/box_test.py +++ b/tests/box_test.py @@ -1,12 +1,8 @@ from fastapi.testclient import TestClient from fastapi import BackgroundTasks import pytest -from unittest.mock import Mock, patch -import asyncio import os from main import app -from services.file import FileService -from services.task import TaskService @@ -146,9 +142,71 @@ async def test_open_box(): assert response.status_code == 201 assert response.json()["success"] == True +@pytest.mark.asyncio +async def test_delete_open_box(): + with open(TEST_FILE_PATH, "rb") as test_file: + files = {"file": test_file} + + # Make request + response = client.post("/api/files", data=DEFAULT_METADATA, files=files) + file_id = response.json()["files"][0]["id"] + + # Check response + assert response.status_code == 201 + assert response.json()["success"] == True + + file_data = response.json()["files"][0] + assert file_data["source"] == DEFAULT_METADATA["source"] + assert file_data["type"] == DEFAULT_METADATA["type"] + assert file_data["status"] == "pending" + assert file_data["service"] == None + assert file_data["filename"] == "manabox_test_file.csv" + assert file_data["filesize_kb"] == get_file_size_kb(TEST_FILE_PATH) + assert file_data["id"] is not None + + # Execute background tasks if they were added + background_tasks = BackgroundTasks() + for task in background_tasks.tasks: + await task() + + # Create a box first + create_response = client.post("/api/boxes", + data={ + "type": "play", + "set_code": "INR", + "sku": "1423", + "num_cards_expected": 504 + } + ) + box_id = create_response.json()["box"][0]["product_id"] + + # Open the box + open_response = client.post(f"/api/boxes/{box_id}/open", + data={ + "product_id": box_id, + "file_ids": [file_id], + "num_cards_actual": 500 + } + ) + + # Check if the box is opened + assert open_response.status_code == 201 + assert open_response.json()["success"] == True + + # Get the open box ID + open_box_id = open_response.json()["open_box"][0]["id"] + + # Delete the open box + response = client.delete(f"/api/boxes/{open_box_id}/open") + + assert response.status_code == 200 + assert response.json()["success"] == True + def test_cleanup(): + cleanup = True # Delete all boxes created during testing - for box_id in test_boxes: - client.delete(f"/api/boxes/{box_id}") + if cleanup: + for box_id in test_boxes: + client.delete(f"/api/boxes/{box_id}") \ No newline at end of file