lots
This commit is contained in:
62
alembic/versions/420691c16f3c_asdf.py
Normal file
62
alembic/versions/420691c16f3c_asdf.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""asdf
|
||||
|
||||
Revision ID: 420691c16f3c
|
||||
Revises: 236605bcac6e
|
||||
Create Date: 2025-07-26 14:32:15.012286
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '420691c16f3c'
|
||||
down_revision: Union[str, None] = '236605bcac6e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('inventory_labels',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('uuid', sa.String(), nullable=True),
|
||||
sa.Column('upc', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_inventory_labels_id'), 'inventory_labels', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_inventory_labels_upc'), 'inventory_labels', ['upc'], unique=False)
|
||||
op.create_index(op.f('ix_inventory_labels_uuid'), 'inventory_labels', ['uuid'], unique=False)
|
||||
op.create_table('inventory_label_metadata',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('inventory_label_id', sa.Integer(), nullable=True),
|
||||
sa.Column('metadata_key', sa.String(), nullable=True),
|
||||
sa.Column('metadata_value', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['inventory_label_id'], ['inventory_labels.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_inventory_label_metadata_id'), 'inventory_label_metadata', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_inventory_label_metadata_metadata_key'), 'inventory_label_metadata', ['metadata_key'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_inventory_label_metadata_metadata_key'), table_name='inventory_label_metadata')
|
||||
op.drop_index(op.f('ix_inventory_label_metadata_id'), table_name='inventory_label_metadata')
|
||||
op.drop_table('inventory_label_metadata')
|
||||
op.drop_index(op.f('ix_inventory_labels_uuid'), table_name='inventory_labels')
|
||||
op.drop_index(op.f('ix_inventory_labels_upc'), table_name='inventory_labels')
|
||||
op.drop_index(op.f('ix_inventory_labels_id'), table_name='inventory_labels')
|
||||
op.drop_table('inventory_labels')
|
||||
# ### end Alembic commands ###
|
26
app/main.py
26
app/main.py
@@ -58,12 +58,12 @@ async def lifespan(app: FastAPI):
|
||||
# Get a database session
|
||||
db = SessionLocal()
|
||||
try:
|
||||
#data_init_service = service_manager.get_service('data_initialization')
|
||||
#data_init = await data_init_service.initialize_data(db, game_ids=[1], use_cache=False, init_categories=True, init_products=True, init_groups=True, init_archived_prices=True, init_mtgjson=False, archived_prices_start_date="2025-06-07", archived_prices_end_date="2025-06-08")
|
||||
#logger.info(f"Data initialization results: {data_init}")
|
||||
data_init_service = service_manager.get_service('data_initialization')
|
||||
data_init = await data_init_service.initialize_data(db, game_ids=[1,62], use_cache=False, init_categories=True, init_products=True, init_groups=True, init_archived_prices=True, init_mtgjson=True, archived_prices_start_date="2025-07-28", archived_prices_end_date="2025-07-30")
|
||||
logger.info(f"Data initialization results: {data_init}")
|
||||
# Update most recent prices
|
||||
#MostRecentTCGPlayerPrice.update_most_recent_prices(db)
|
||||
#logger.info("Most recent prices updated successfully")
|
||||
MostRecentTCGPlayerPrice.update_most_recent_prices(db)
|
||||
logger.info("Most recent prices updated successfully")
|
||||
|
||||
# Create default customer, vendor, and marketplace
|
||||
#inv_data_init = await data_init_service.initialize_inventory_data(db)
|
||||
@@ -155,6 +155,22 @@ async def read_styles_css():
|
||||
raise HTTPException(status_code=404, detail="Styles.css file not found")
|
||||
return FileResponse(css_path)
|
||||
|
||||
# serve inventory_labels.html
|
||||
@app.get("/inventory_labels.html")
|
||||
async def read_inventory_labels_html():
|
||||
html_path = Path('app/static/inventory_labels.html')
|
||||
if not html_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Inventory_labels.html file not found")
|
||||
return FileResponse(html_path)
|
||||
|
||||
# serve inventory_labels.js
|
||||
@app.get("/inventory_labels.js")
|
||||
async def read_inventory_labels_js():
|
||||
js_path = Path('app/static/inventory_labels.js')
|
||||
if not js_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Inventory_labels.js file not found")
|
||||
return FileResponse(js_path)
|
||||
|
||||
# Configure CORS with specific origins in production
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
|
@@ -318,4 +318,32 @@ class MarketplaceListing(Base):
|
||||
inventory_item = relationship("InventoryItem", back_populates="marketplace_listing")
|
||||
marketplace = relationship("Marketplace", back_populates="listings")
|
||||
recommended_price = relationship("PricingEvent", foreign_keys=[recommended_price_id])
|
||||
listed_price = relationship("PricingEvent", foreign_keys=[listed_price_id])
|
||||
listed_price = relationship("PricingEvent", foreign_keys=[listed_price_id])
|
||||
|
||||
|
||||
class InventoryLabel(Base):
|
||||
__tablename__ = "inventory_labels"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String, index=True)
|
||||
upc = Column(String, index=True, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
metadata_entries = relationship("InventoryLabelMetadata", back_populates="inventory_label")
|
||||
|
||||
class InventoryLabelMetadata(Base):
|
||||
__tablename__ = "inventory_label_metadata"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
inventory_label_id = Column(Integer, ForeignKey("inventory_labels.id"))
|
||||
metadata_key = Column(String, index=True)
|
||||
metadata_value = Column(String)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
inventory_label = relationship("InventoryLabel", back_populates="metadata_entries")
|
46
app/routes/inventory_label_routes.py
Normal file
46
app/routes/inventory_label_routes.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, func
|
||||
from app.db.database import get_db
|
||||
from app.services.service_manager import ServiceManager
|
||||
from app.schemas.inventory_label import InventoryLabelCreate, InventoryLabelGet, InventoryLabelUpdate, InventoryLabelDelete, InventoryLabelResponse
|
||||
|
||||
router = APIRouter(prefix="/inventory-labels")
|
||||
|
||||
service_manager = ServiceManager()
|
||||
|
||||
# create
|
||||
@router.post("/")
|
||||
async def create_inventory_label(
|
||||
inventory_label: InventoryLabelCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_label_service = service_manager.get_service('inventory_label')
|
||||
return await inventory_label_service.create_inventory_label(db, inventory_label)
|
||||
|
||||
# get
|
||||
@router.get("/")
|
||||
async def get_inventory_label(
|
||||
inventory_label_get: InventoryLabelGet,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
inventory_label_service = service_manager.get_service('inventory_label')
|
||||
return await inventory_label_service.get_inventory_label(db, inventory_label_get)
|
||||
|
||||
# update
|
||||
@router.post("/{inventory_label_id}")
|
||||
async def update_inventory_label(
|
||||
inventory_label_id: int,
|
||||
inventory_label: InventoryLabelUpdate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
pass
|
||||
|
||||
# delete
|
||||
@router.delete("/{inventory_label_id}")
|
||||
async def delete_inventory_label(
|
||||
inventory_label_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
pass
|
@@ -7,6 +7,8 @@ from app.routes.set_label_routes import router as set_label_router
|
||||
from app.routes.order_routes import router as order_router
|
||||
from app.routes.manabox_routes import router as manabox_router
|
||||
from app.routes.inventory_management_routes import router as inventory_management_router
|
||||
from app.routes.inventory_label_routes import router as inventory_label_router
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
# Include set label routes
|
||||
@@ -21,6 +23,9 @@ router.include_router(manabox_router)
|
||||
# Include inventory management routes
|
||||
router.include_router(inventory_management_router)
|
||||
|
||||
# Include inventory label routes
|
||||
router.include_router(inventory_label_router)
|
||||
|
||||
# ============================================================================
|
||||
# Health Check & Root Endpoints
|
||||
# ============================================================================
|
||||
|
45
app/schemas/inventory_label.py
Normal file
45
app/schemas/inventory_label.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
# request
|
||||
# crud
|
||||
|
||||
class InventoryLabelMetadataCreate(BaseModel):
|
||||
key: str
|
||||
value: str
|
||||
|
||||
class InventoryLabelCreate(BaseModel):
|
||||
upc: Optional[str] = None
|
||||
metadata: Optional[List[InventoryLabelMetadataCreate]] = None
|
||||
print: Optional[bool] = True
|
||||
|
||||
class InventoryLabelGet(BaseModel):
|
||||
upc: Optional[str] = None
|
||||
uuid: Optional[str] = None
|
||||
inventory_label_id: Optional[int] = None
|
||||
input_data: Optional[str] = None
|
||||
|
||||
class InventoryLabelUpdate(BaseModel):
|
||||
inventory_label_id: int
|
||||
upc: Optional[str] = None
|
||||
uuid: Optional[str] = None
|
||||
input_data: Optional[str] = None
|
||||
metadata: Optional[List[InventoryLabelMetadataCreate]] = None
|
||||
|
||||
class InventoryLabelDelete(BaseModel):
|
||||
inventory_label_id: int
|
||||
upc: Optional[str] = None
|
||||
uuid: Optional[str] = None
|
||||
input_data: Optional[str] = None
|
||||
|
||||
# response
|
||||
|
||||
class InventoryLabelMetadataResponse(BaseModel):
|
||||
key: str
|
||||
value: str
|
||||
|
||||
class InventoryLabelResponse(BaseModel):
|
||||
upc: Optional[str] = None
|
||||
uuid: Optional[str] = None
|
||||
metadata: Optional[List[InventoryLabelMetadataResponse]] = None
|
@@ -56,7 +56,7 @@ class TCGPlayerAPIOrder(BaseModel):
|
||||
orderFulfillment: str
|
||||
orderNumber: str
|
||||
sellerName: str
|
||||
buyerName: str
|
||||
buyerName: Optional[str] = None
|
||||
paymentType: str
|
||||
pickupStatus: str
|
||||
shippingType: str
|
||||
@@ -74,7 +74,7 @@ class TCGPlayerAPIOrderSummary(BaseModel):
|
||||
orderDate: datetime
|
||||
orderChannel: str
|
||||
orderStatus: str
|
||||
buyerName: str
|
||||
buyerName: Optional[str] = None
|
||||
shippingType: str
|
||||
itemQuantity: int
|
||||
productAmount: float
|
||||
|
@@ -71,7 +71,7 @@ class TCGPlayerProductResponse(BaseModel):
|
||||
category_id: int
|
||||
group_id: int
|
||||
url: str
|
||||
market_price: float
|
||||
market_price: Optional[float] = None
|
||||
category_name: str
|
||||
group_name: str
|
||||
|
||||
|
@@ -313,6 +313,9 @@ class DataInitializationService(BaseService):
|
||||
else:
|
||||
# Get CSV data from API
|
||||
csv_data = await tcgcsv_service.get_products_and_prices(game_id, group.group_id)
|
||||
if not csv_data:
|
||||
logger.warning(f"No products data found for group {group.group_id}")
|
||||
continue
|
||||
|
||||
# Save the CSV file
|
||||
await self.file_service.save_file(
|
||||
@@ -428,6 +431,9 @@ class DataInitializationService(BaseService):
|
||||
logger.info(f"Downloading and processing archived prices for {date}")
|
||||
# Download and extract the archive
|
||||
archive_data = await tcgcsv_service.get_archived_prices_for_date(date)
|
||||
if not archive_data:
|
||||
logger.warning(f"No archive data found for {date}")
|
||||
continue
|
||||
|
||||
# Save the archive file
|
||||
file_record = await self.file_service.save_file(
|
||||
|
@@ -67,7 +67,9 @@ class BaseExternalService:
|
||||
logger.error(f"Failed to parse JSON response: {e}")
|
||||
return raw_response
|
||||
return raw_response
|
||||
|
||||
except aiohttp.ClientResponseError as e:
|
||||
logger.error(f"Request failed: {e}")
|
||||
raise
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"Request failed: {e}")
|
||||
raise
|
||||
|
@@ -3,6 +3,9 @@ from datetime import datetime, timedelta
|
||||
import csv
|
||||
import io
|
||||
from app.services.external_api.base_external_service import BaseExternalService
|
||||
import aiohttp
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TCGCSVService(BaseExternalService):
|
||||
def __init__(self):
|
||||
@@ -16,7 +19,14 @@ class TCGCSVService(BaseExternalService):
|
||||
async def get_products_and_prices(self, game_id: str, group_id: int) -> str:
|
||||
"""Fetch products and prices for a specific group from TCGCSV API"""
|
||||
endpoint = f"tcgplayer/{game_id}/{group_id}/ProductsAndPrices.csv"
|
||||
return await self._make_request("GET", endpoint, headers={"Accept": "text/csv"})
|
||||
try:
|
||||
return await self._make_request("GET", endpoint, headers={"Accept": "text/csv"})
|
||||
except aiohttp.ClientResponseError as e:
|
||||
if e.status == 403:
|
||||
logger.error(f"Request failed: {e}")
|
||||
return None
|
||||
else:
|
||||
raise
|
||||
|
||||
async def get_categories(self) -> Dict[str, Any]:
|
||||
"""Fetch all categories from TCGCSV API"""
|
||||
@@ -26,7 +36,14 @@ class TCGCSVService(BaseExternalService):
|
||||
async def get_archived_prices_for_date(self, date_str: str) -> bytes:
|
||||
"""Fetch archived prices from TCGCSV API"""
|
||||
endpoint = f"archive/tcgplayer/prices-{date_str}.ppmd.7z"
|
||||
return await self._make_request("GET", endpoint, binary=True)
|
||||
try:
|
||||
return await self._make_request("GET", endpoint, binary=True)
|
||||
except aiohttp.ClientResponseError as e:
|
||||
if e.status == 403:
|
||||
logger.error(f"Request failed: {e}")
|
||||
return None
|
||||
else:
|
||||
raise
|
||||
|
||||
async def get_tcgcsv_date_range(self, start_date: datetime, end_date: datetime) -> List[datetime]:
|
||||
"""Get a date range for a given start and end date"""
|
||||
|
@@ -10,6 +10,8 @@ class BaseTCGPlayerService(BaseExternalService):
|
||||
STORE_BASE_URL = "https://store.tcgplayer.com"
|
||||
LOGIN_ENDPOINT = "/oauth/login"
|
||||
PRICING_ENDPOINT = "/Admin/Pricing"
|
||||
MP_SEARCH_URL = "https://mp-search-api.tcgplayer.com/v1"
|
||||
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(base_url=self.STORE_BASE_URL)
|
||||
|
@@ -407,8 +407,8 @@ class OrderManagementService(BaseTCGPlayerService):
|
||||
('extended_price', 'extendedPrice'),
|
||||
('quantity', 'quantity'),
|
||||
('url', 'url'),
|
||||
('tcgplayer_product_id', 'productId'),
|
||||
('tcgplayer_sku_id', 'skuId')
|
||||
('product_id', 'productId'),
|
||||
('sku_id', 'skuId')
|
||||
]
|
||||
|
||||
for db_field, api_field in product_fields_to_compare:
|
||||
|
@@ -0,0 +1,54 @@
|
||||
from app.services.external_api.tcgplayer.base_tcgplayer_service import BaseTCGPlayerService
|
||||
from sqlalchemy.orm import Session
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class TCGPlayerMarketDataInput:
|
||||
product_id: str
|
||||
mpfev: str
|
||||
condition: str
|
||||
language: str
|
||||
printing: str
|
||||
quantity: int
|
||||
|
||||
class TCGPlayerMarketDataService(BaseTCGPlayerService):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
async def get_active_listings(self, db: Session, input: TCGPlayerMarketDataInput):
|
||||
listings_endpoint = f"{self.MP_SEARCH_URL}/product/{input.product_id}/listings?mpfev={input.mpfev}"
|
||||
"""
|
||||
curl 'https://mp-search-api.tcgplayer.com/v1/product/631066/listings?mpfev=3816' \
|
||||
-H 'accept: application/json, text/plain, */*' \
|
||||
-H 'accept-language: en-US,en;q=0.5' \
|
||||
-H 'content-type: application/json' \
|
||||
-b 'tracking-preferences={%22version%22:1%2C%22destinations%22:{%22Actions%20Amplitude%22:false%2C%22AdWords%22:false%2C%22Google%20AdWords%20New%22:false%2C%22Google%20Enhanced%20Conversions%22:false%2C%22Google%20Tag%20Manager%22:false%2C%22Impact%20Partnership%20Cloud%22:false%2C%22Optimizely%22:false}%2C%22custom%22:{%22advertising%22:false%2C%22functional%22:false%2C%22marketingAndAnalytics%22:false}}; tcg-uuid=4321c3a3-bdc0-4a3f-952f-ada250ea91ab; product-display-settings=sort=price+shipping&size=10; setting=CD=US&M=1; brwsr=f9b5ab04-479f-11f0-9996-95b462f705de; TCG_VisitorKey=1cfd1431-fc5d-461b-9fb3-61de387f3342; SellerProximity=ZipCode=&MaxSellerDistance=1000&IsActive=false; OAuthLoginSessionId=766a573b-d2ba-4285-8eae-c79b5c8a877c; TCGAuthTicket_Production=3C334E06A27B20FDD326A6C20C7FFDEFECD7EDB73BBE7E2A072607D3417CFDC3B1A12EDA8F5B4F393380FA2CA8FCF596476F2BEC3B54FDE788D57A05745D8820DF0897F3B673BACD6487BDA6CC0780896CB382DCFAB9AFC90B747ED5561CE5B7B8E122D0815203F93DE6EDB73894CE9CD20D6090; BuyerRevalidationKey=; ASP.NET_SessionId=s04smsk3opzinl2tl31x042r; __RequestVerificationToken_L2FkbWlu0=TnVB3O7LFL0SbCOd2ULkhadaytHVM8uXJqi8b-27w6WdPQ3QU9P76z92HmVS-i4K0SjbPDbvGe8grkme7l4m6fgetX01; LastSeller=e576ed4c; StoreSaveForLater_PRODUCTION=SFLK=4db1ce3215c84eaca7439f889cd70b79&Ignore=false; SearchCriteria=M=1&WantVerifiedSellers=False&WantDirect=False&WantSellersInCart=False&WantWPNSellers=False; SearchSortSettings=M=1&ProductSortOption=Sales&ProductSortDesc=False&PriceSortOption=Shipping&ProductResultDisplay=grid; tcg-segment-session=1749821401966%257C1749822225413' \
|
||||
-H 'origin: https://www.tcgplayer.com' \
|
||||
-H 'priority: u=1, i' \
|
||||
-H 'referer: https://www.tcgplayer.com/' \
|
||||
-H 'sec-ch-ua: "Brave";v="137", "Chromium";v="137", "Not/A)Brand";v="24"' \
|
||||
-H 'sec-ch-ua-mobile: ?0' \
|
||||
-H 'sec-ch-ua-platform: "Linux"' \
|
||||
-H 'sec-fetch-dest: empty' \
|
||||
-H 'sec-fetch-mode: cors' \
|
||||
-H 'sec-fetch-site: same-site' \
|
||||
-H 'sec-gpc: 1' \
|
||||
-H 'user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36' \
|
||||
--data-raw '{"filters":{"term":{"sellerStatus":"Live","channelId":0,"language":["English"],"condition":["Near Mint"],"printing":["Foil"]},"range":{"quantity":{"gte":2}},"exclude":{"channelExclusion":0}},"from":0,"size":10,"sort":{"field":"price+shipping","order":"asc"},"context":{"shippingCountry":"US","cart":{}},"aggregations":["listingType"]}'
|
||||
"""
|
||||
headers = {
|
||||
'accept': 'application/json, text/plain, */*',
|
||||
'accept-language': 'en-US,en;q=0.5',
|
||||
'content-type': 'application/json',
|
||||
'origin': 'https://www.tcgplayer.com',
|
||||
'priority': 'u=1, i',
|
||||
'referer': 'https://www.tcgplayer.com/',
|
||||
'sec-ch-ua': '"Brave";v="137", "Chromium";v="137", "Not/A)Brand";v="24"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': 'Linux',
|
||||
'sec-fetch-dest': 'empty',
|
||||
'sec-fetch-mode': 'cors',
|
||||
'sec-fetch-site': 'same-site',
|
||||
'sec-gpc': '1',
|
||||
'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'
|
||||
}
|
438
app/services/inventory_label_service.py
Normal file
438
app/services/inventory_label_service.py
Normal file
@@ -0,0 +1,438 @@
|
||||
from app.services.base_service import BaseService
|
||||
from app.models.inventory_management import InventoryLabel, InventoryLabelMetadata
|
||||
from app.schemas.inventory_label import InventoryLabelCreate, InventoryLabelUpdate, InventoryLabelDelete, InventoryLabelGet, InventoryLabelMetadataCreate
|
||||
from app.db.database import transaction as db_transaction
|
||||
import uuid as uuid_lib
|
||||
import re
|
||||
from sqlalchemy.orm import Session
|
||||
import qrcode
|
||||
from io import BytesIO
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from typing import Optional
|
||||
|
||||
class InventoryLabelService(BaseService):
|
||||
def __init__(self):
|
||||
super().__init__(None)
|
||||
|
||||
def _convert_uuid_to_qr_code(self, uuid_string: str) -> bytes:
|
||||
"""
|
||||
Convert a UUID string to a QR code image as bytes.
|
||||
|
||||
Args:
|
||||
uuid_string: The UUID string to encode
|
||||
|
||||
Returns:
|
||||
bytes: The QR code image as bytes
|
||||
"""
|
||||
# Create QR code instance
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_M, # Medium error correction for better reliability
|
||||
box_size=8, # Smaller box size for better fit on labels
|
||||
border=2, # Smaller border to maximize QR code size
|
||||
)
|
||||
|
||||
# Add the UUID data to the QR code
|
||||
qr.add_data(uuid_string)
|
||||
qr.make(fit=True)
|
||||
|
||||
# Create the QR code image
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# Convert to bytes
|
||||
img_buffer = BytesIO()
|
||||
img.save(img_buffer, format='PNG')
|
||||
img_buffer.seek(0)
|
||||
|
||||
return img_buffer.getvalue()
|
||||
|
||||
def _create_composite_label_image(self, qr_code_image: Image.Image, text: str, label_width: int = 991, label_height: int = 306) -> Image.Image:
|
||||
"""
|
||||
Create a composite image with QR code left-aligned and text right-aligned.
|
||||
|
||||
Args:
|
||||
qr_code_image: The QR code image to place on the left
|
||||
text: The text to place on the right
|
||||
label_width: Width of the label in pixels
|
||||
label_height: Height of the label in pixels
|
||||
|
||||
Returns:
|
||||
Image.Image: The composite label image
|
||||
"""
|
||||
# Create a new white canvas
|
||||
label_canvas = Image.new('RGB', (label_width, label_height), 'white')
|
||||
|
||||
# Calculate QR code size (square, fit within label height with margin)
|
||||
qr_margin = 20
|
||||
max_qr_size = label_height - (2 * qr_margin)
|
||||
qr_size = min(max_qr_size, label_width // 2) # QR takes up to half the width
|
||||
|
||||
# Resize QR code to fit
|
||||
resized_qr = qr_code_image.resize((qr_size, qr_size), Image.Resampling.LANCZOS)
|
||||
|
||||
# Position QR code on the left with margin
|
||||
qr_x = qr_margin
|
||||
qr_y = (label_height - qr_size) // 2 # Center vertically
|
||||
|
||||
# Paste QR code onto canvas
|
||||
label_canvas.paste(resized_qr, (qr_x, qr_y))
|
||||
|
||||
# Add text on the right side
|
||||
draw = ImageDraw.Draw(label_canvas)
|
||||
|
||||
# Try to use a default font, fall back to basic font if not available
|
||||
font_size = 24
|
||||
font = None
|
||||
|
||||
# Try multiple font paths
|
||||
font_paths = [
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/System/Library/Fonts/Arial.ttf",
|
||||
"/usr/share/fonts/TTF/Arial.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf"
|
||||
]
|
||||
|
||||
for font_path in font_paths:
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
break
|
||||
except (OSError, IOError):
|
||||
continue
|
||||
|
||||
# Fall back to default font if no system font found
|
||||
if font is None:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Calculate text position (right-aligned with margin)
|
||||
text_margin = 20
|
||||
text_x = label_width - text_margin
|
||||
text_y = label_height // 2 # Center vertically
|
||||
|
||||
# Get text bounding box to position it properly
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
# Adjust position to right-align the text
|
||||
text_x = text_x - text_width
|
||||
text_y = text_y - (text_height // 2)
|
||||
|
||||
# Draw the text
|
||||
draw.text((text_x, text_y), text, fill='black', font=font)
|
||||
|
||||
return label_canvas
|
||||
|
||||
def _create_qr_code_with_text(self, uuid_string: str, text: str) -> bytes:
|
||||
"""
|
||||
Create a QR code image with text and return it as bytes.
|
||||
|
||||
Args:
|
||||
uuid_string: The UUID string to encode in QR code
|
||||
text: The text to display on the label
|
||||
|
||||
Returns:
|
||||
bytes: The composite image as bytes
|
||||
"""
|
||||
# Create QR code instance
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||||
box_size=8,
|
||||
border=2,
|
||||
)
|
||||
|
||||
# Add the UUID data to the QR code
|
||||
qr.add_data(uuid_string)
|
||||
qr.make(fit=True)
|
||||
|
||||
# Create the QR code image
|
||||
qr_image = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# Create composite image with text
|
||||
composite_image = self._create_composite_label_image(qr_image, text)
|
||||
|
||||
# Convert to bytes
|
||||
img_buffer = BytesIO()
|
||||
composite_image.save(img_buffer, format='PNG')
|
||||
img_buffer.seek(0)
|
||||
|
||||
return img_buffer.getvalue()
|
||||
|
||||
def create_qr_code_with_text_direct(self, uuid_string: str, text: str) -> bytes:
|
||||
"""
|
||||
Create a QR code image with text and return it as bytes directly.
|
||||
This method doesn't involve database operations and is useful for testing.
|
||||
|
||||
Args:
|
||||
uuid_string: The UUID string to encode in QR code
|
||||
text: The text to display on the label
|
||||
|
||||
Returns:
|
||||
bytes: The composite image as bytes
|
||||
"""
|
||||
return self._create_qr_code_with_text(uuid_string, text)
|
||||
|
||||
# create
|
||||
async def create_inventory_label(self, db: Session, inventory_label: InventoryLabelCreate, print: bool = True) -> InventoryLabel:
|
||||
file_service = self.get_service('file')
|
||||
# check if we have a upc
|
||||
if inventory_label.upc:
|
||||
# validate the upc
|
||||
if not self._is_valid_upc(inventory_label.upc):
|
||||
raise ValueError("Invalid UPC")
|
||||
# check if we have metadata
|
||||
if inventory_label.metadata:
|
||||
# validate the metadata
|
||||
for metadata in inventory_label.metadata:
|
||||
if not metadata.key or not metadata.value:
|
||||
raise ValueError("Invalid metadata")
|
||||
# generate a uuid
|
||||
label_uuid = str(uuid_lib.uuid4())
|
||||
with db_transaction(db):
|
||||
# create the inventory label
|
||||
inventory_label_model = InventoryLabel(uuid=label_uuid, upc=inventory_label.upc)
|
||||
db.add(inventory_label_model)
|
||||
db.flush()
|
||||
# add the metadata
|
||||
if inventory_label.metadata:
|
||||
for metadata in inventory_label.metadata:
|
||||
inventory_label_metadata_model = InventoryLabelMetadata(inventory_label_id=inventory_label_model.id, metadata_key=metadata.key, metadata_value=metadata.value)
|
||||
db.add(inventory_label_metadata_model)
|
||||
if print:
|
||||
# Create image with QR code and optional text
|
||||
if inventory_label.metadata and len(inventory_label.metadata) > 0:
|
||||
if inventory_label.upc:
|
||||
# add upc to metadata
|
||||
inventory_label.metadata.append(InventoryLabelMetadataCreate(key="upc", value=inventory_label.upc))
|
||||
# concat metadata key values separated by newlines and :
|
||||
text = "\n".join([f"{metadata.key}: {metadata.value}" for metadata in inventory_label.metadata])
|
||||
# Use composite image with QR code and text
|
||||
image_data = self._create_qr_code_with_text(label_uuid, text)
|
||||
else:
|
||||
# Use original QR code only
|
||||
image_data = self._convert_uuid_to_qr_code(label_uuid)
|
||||
|
||||
# save file
|
||||
filename = f"{label_uuid}.png"
|
||||
file_record = await file_service.save_file(
|
||||
db=db,
|
||||
file_data=image_data,
|
||||
filename=filename,
|
||||
subdir="inventory_labels",
|
||||
file_type="inventory_label",
|
||||
content_type="image/png",
|
||||
metadata={"uuid": label_uuid}
|
||||
)
|
||||
print_service = self.get_service('label_printer')
|
||||
await print_service.print_file(file_record.path, label_size="dk1201", label_type="inventory_label", copies=1)
|
||||
return inventory_label_model
|
||||
|
||||
# get
|
||||
|
||||
def classify_input_data(self, input_data: str) -> str:
|
||||
"""
|
||||
Classify input data as UPC, UUID, or other string with high accuracy.
|
||||
|
||||
Args:
|
||||
input_data: The string to classify
|
||||
|
||||
Returns:
|
||||
str: "upc", "uuid", or "other"
|
||||
"""
|
||||
if not input_data or not isinstance(input_data, str):
|
||||
return "other"
|
||||
|
||||
# Remove any whitespace
|
||||
input_data = input_data.strip()
|
||||
|
||||
# Check for UUID first (more specific pattern)
|
||||
if self._is_valid_uuid(input_data):
|
||||
return "uuid"
|
||||
|
||||
# Check for UPC code
|
||||
if self._is_valid_upc(input_data):
|
||||
return "upc"
|
||||
|
||||
return "other"
|
||||
|
||||
def _is_valid_uuid(self, uuid_string: str) -> bool:
|
||||
"""
|
||||
Validate if string is a proper UUID.
|
||||
|
||||
Args:
|
||||
uuid_string: String to validate
|
||||
|
||||
Returns:
|
||||
bool: True if valid UUID, False otherwise
|
||||
"""
|
||||
# UUID regex pattern for all versions
|
||||
uuid_pattern = re.compile(
|
||||
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
if not uuid_pattern.match(uuid_string):
|
||||
return False
|
||||
|
||||
try:
|
||||
# Validate UUID structure and version
|
||||
uuid_obj = uuid_lib.UUID(uuid_string)
|
||||
# Accept all UUID versions (1, 3, 4, 5)
|
||||
return uuid_obj.version in [1, 3, 4, 5]
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def _is_valid_upc(self, upc_string: str) -> bool:
|
||||
"""
|
||||
Validate if string is a proper UPC code.
|
||||
|
||||
Args:
|
||||
upc_string: String to validate
|
||||
|
||||
Returns:
|
||||
bool: True if valid UPC, False otherwise
|
||||
"""
|
||||
# Remove any non-digit characters
|
||||
digits_only = re.sub(r'[^0-9]', '', upc_string)
|
||||
|
||||
# UPC-A must be exactly 12 digits
|
||||
if len(digits_only) == 12:
|
||||
return self._validate_upc_a_checksum(digits_only)
|
||||
|
||||
# UPC-E must be exactly 8 digits
|
||||
if len(digits_only) == 8:
|
||||
return self._validate_upc_e_checksum(digits_only)
|
||||
|
||||
# EAN-13 must be exactly 13 digits
|
||||
if len(digits_only) == 13:
|
||||
return self._validate_ean_13_checksum(digits_only)
|
||||
|
||||
return False
|
||||
|
||||
def _validate_upc_a_checksum(self, upc: str) -> bool:
|
||||
"""
|
||||
Validate UPC-A checksum.
|
||||
|
||||
Args:
|
||||
upc: 12-digit UPC string
|
||||
|
||||
Returns:
|
||||
bool: True if checksum is valid
|
||||
"""
|
||||
if len(upc) != 12 or not upc.isdigit():
|
||||
return False
|
||||
|
||||
# Calculate checksum
|
||||
total = 0
|
||||
for i in range(11):
|
||||
digit = int(upc[i])
|
||||
if i % 2 == 0: # Odd positions (0-indexed)
|
||||
total += digit * 3
|
||||
else: # Even positions
|
||||
total += digit
|
||||
|
||||
checksum = (10 - (total % 10)) % 10
|
||||
return checksum == int(upc[11])
|
||||
|
||||
def _validate_upc_e_checksum(self, upc: str) -> bool:
|
||||
"""
|
||||
Validate UPC-E checksum.
|
||||
|
||||
Args:
|
||||
upc: 8-digit UPC-E string
|
||||
|
||||
Returns:
|
||||
bool: True if checksum is valid
|
||||
"""
|
||||
if len(upc) != 8 or not upc.isdigit():
|
||||
return False
|
||||
|
||||
# Calculate checksum
|
||||
total = 0
|
||||
for i in range(7):
|
||||
digit = int(upc[i])
|
||||
if i % 2 == 0: # Odd positions (0-indexed)
|
||||
total += digit * 3
|
||||
else: # Even positions
|
||||
total += digit
|
||||
|
||||
checksum = (10 - (total % 10)) % 10
|
||||
return checksum == int(upc[7])
|
||||
|
||||
def _validate_ean_13_checksum(self, ean: str) -> bool:
|
||||
"""
|
||||
Validate EAN-13 checksum.
|
||||
|
||||
Args:
|
||||
ean: 13-digit EAN string
|
||||
|
||||
Returns:
|
||||
bool: True if checksum is valid
|
||||
"""
|
||||
if len(ean) != 13 or not ean.isdigit():
|
||||
return False
|
||||
|
||||
# Calculate checksum
|
||||
total = 0
|
||||
for i in range(12):
|
||||
digit = int(ean[i])
|
||||
if i % 2 == 0: # Even positions (0-indexed)
|
||||
total += digit
|
||||
else: # Odd positions
|
||||
total += digit * 3
|
||||
|
||||
checksum = (10 - (total % 10)) % 10
|
||||
return checksum == int(ean[12])
|
||||
|
||||
async def get_inventory_label(self, db: Session, inventory_label_get: InventoryLabelGet) -> InventoryLabel:
|
||||
"""
|
||||
Get an inventory label by classifying the input data and querying the appropriate field.
|
||||
|
||||
Args:
|
||||
inventory_label_get: InventoryLabelGet object containing input_data
|
||||
|
||||
Returns:
|
||||
InventoryLabel: The found inventory label or None
|
||||
"""
|
||||
# check if we have a uuid or upc
|
||||
if inventory_label_get.uuid:
|
||||
return self._get_by_uuid(db, inventory_label_get.uuid)
|
||||
elif inventory_label_get.upc:
|
||||
return self._get_by_upc(db, inventory_label_get.upc)
|
||||
else:
|
||||
# check if we have input_data
|
||||
if inventory_label_get.input_data:
|
||||
# classify the input data
|
||||
input_type = self.classify_input_data(inventory_label_get.input_data)
|
||||
if input_type == "upc":
|
||||
return self._get_by_upc(db, inventory_label_get.input_data)
|
||||
elif input_type == "uuid":
|
||||
return self._get_by_uuid(db, inventory_label_get.input_data)
|
||||
else:
|
||||
raise ValueError("Invalid input data")
|
||||
else:
|
||||
raise ValueError("Invalid input data")
|
||||
|
||||
def _get_by_upc(self, db: Session, upc: str) -> InventoryLabel:
|
||||
"""
|
||||
Get inventory label by UPC.
|
||||
|
||||
Args:
|
||||
upc: The UPC code to search for
|
||||
|
||||
Returns:
|
||||
InventoryLabel: The found inventory label or None
|
||||
"""
|
||||
return db.query(InventoryLabel).filter(InventoryLabel.upc == upc).first()
|
||||
|
||||
def _get_by_uuid(self, db: Session, uuid: str) -> InventoryLabel:
|
||||
"""
|
||||
Get inventory label by UUID.
|
||||
|
||||
Args:
|
||||
uuid: The UUID to search for
|
||||
|
||||
Returns:
|
||||
InventoryLabel: The found inventory label or None
|
||||
"""
|
||||
return db.query(InventoryLabel).filter(InventoryLabel.uuid == uuid).first()
|
@@ -263,7 +263,7 @@ class BoxService(BaseService[Box]):
|
||||
# ensure card
|
||||
if resulting_card.item_type != "card":
|
||||
raise ValueError(f"Expected card, got {resulting_card.item_type}")
|
||||
resulting_card_market_value = resulting_card.products.most_recent_tcgplayer_price.market_price
|
||||
resulting_card_market_value = resulting_card.products.most_recent_tcgplayer_price.market_price if resulting_card.products.most_recent_tcgplayer_price.market_price is not None else resulting_card.products.most_recent_tcgplayer_price.low_price
|
||||
resulting_card_cost_basis = (resulting_card_market_value / box_expected_value) * box_cost_basis
|
||||
resulting_card.inventory_item.cost_basis = resulting_card_cost_basis
|
||||
db.flush()
|
||||
|
@@ -142,7 +142,7 @@ class LabelPrinterService:
|
||||
logger.error(f"Unexpected error in _send_print_request: {e}")
|
||||
return False
|
||||
|
||||
async def print_file(self, file_path: Union[str, Path, FileInDB], label_size: Literal["dk1201", "dk1241"], label_type: Optional[Literal["address_label", "packing_slip", "set_label", "return_label", "pirate_ship_label"]] = None, copies: Optional[int] = None) -> bool:
|
||||
async def print_file(self, file_path: Union[str, Path, FileInDB], label_size: Literal["dk1201", "dk1241"], label_type: Optional[Literal["address_label", "packing_slip", "set_label", "return_label", "pirate_ship_label", "inventory_label"]] = None, copies: Optional[int] = None) -> bool:
|
||||
"""Print a PDF or PNG file to the label printer.
|
||||
|
||||
Args:
|
||||
@@ -207,7 +207,7 @@ class LabelPrinterService:
|
||||
resized_image = resized_image.resize((991, 306), Image.Resampling.LANCZOS)
|
||||
|
||||
# if file path contains address_label, rotate image 90 degrees
|
||||
if label_type == "address_label" or label_type == "set_label" or label_type == "return_label":
|
||||
if label_type == "address_label" or label_type == "set_label" or label_type == "return_label" or label_type == "inventory_label":
|
||||
rotate = "90"
|
||||
cut = False
|
||||
else:
|
||||
|
@@ -183,7 +183,7 @@ class PricingService(BaseService):
|
||||
"""
|
||||
# Fetch base pricing data
|
||||
cost_basis = price_data.cost_basis
|
||||
market_price = price_data.market_price
|
||||
market_price = price_data.market_price if price_data.market_price is not None else price_data.tcg_mid
|
||||
tcg_low = price_data.tcg_low
|
||||
tcg_mid = price_data.tcg_mid
|
||||
listed_price = price_data.listed_price
|
||||
|
@@ -87,28 +87,38 @@ class PullSheetService(BaseService):
|
||||
Args:
|
||||
row: pandas Series
|
||||
"""
|
||||
# if rarity is nan, return none
|
||||
if pd.isna(row['Rarity']):
|
||||
return '?'
|
||||
# get category id from set name
|
||||
group_id = db.query(TCGPlayerGroup).filter(TCGPlayerGroup.name == row['Set']).first().group_id
|
||||
# format number
|
||||
number = str(int(row['Number'])) if 'Number' in row and pd.notna(row['Number']) and '/' not in str(row['Number']) else str(row['Number']) if 'Number' in row and pd.notna(row['Number']) and '/' in str(row['Number']) else ''
|
||||
# format number - convert float to int if it's a pure number, otherwise keep as is
|
||||
number = str(int(float(row['Number']))) if 'Number' in row and pd.notna(row['Number']) and str(row['Number']).replace('.', '').isdigit() else str(row['Number']) if 'Number' in row and pd.notna(row['Number']) else ''
|
||||
# get product info from category id
|
||||
product_id = db.query(TCGPlayerProduct).filter(TCGPlayerProduct.group_id == group_id).filter(TCGPlayerProduct.name == row['Product Name']).filter(TCGPlayerProduct.ext_number == number).filter(TCGPlayerProduct.ext_rarity == row['Rarity']).first().tcgplayer_product_id
|
||||
# only do this block if mtg, to do fix this more betterer
|
||||
# get scryfall id from product id
|
||||
mtgjson_id = db.query(MTGJSONSKU).filter(MTGJSONSKU.tcgplayer_product_id == product_id).first().mtgjson_uuid
|
||||
scryfall_id = db.query(MTGJSONCard).filter(MTGJSONCard.mtgjson_uuid == mtgjson_id).first().scryfall_id
|
||||
# get color identity from scryfall
|
||||
scryfall_service = self.get_service('scryfall')
|
||||
color_identity = await scryfall_service.get_color_identity(scryfall_id)
|
||||
if color_identity is None:
|
||||
return '?'
|
||||
# color identity is str of json array, convert to human readable string of list
|
||||
color_identity = [str(color) for color in color_identity]
|
||||
# if color identity is empty, return C for colorless
|
||||
if not color_identity:
|
||||
return 'C'
|
||||
# ensure order, W > U > B > R > G
|
||||
color_identity = sorted(color_identity, key=lambda x: ['W', 'U', 'B', 'R', 'G'].index(x))
|
||||
color_identity = ''.join(color_identity)
|
||||
mtgjson_id = db.query(MTGJSONSKU).filter(MTGJSONSKU.tcgplayer_product_id == product_id).first()
|
||||
if mtgjson_id is not None:
|
||||
scryfall_id = db.query(MTGJSONCard).filter(MTGJSONCard.mtgjson_uuid == mtgjson_id.mtgjson_uuid).first().scryfall_id
|
||||
else:
|
||||
scryfall_id = None
|
||||
if scryfall_id is not None:
|
||||
# get color identity from scryfall
|
||||
scryfall_service = self.get_service('scryfall')
|
||||
color_identity = await scryfall_service.get_color_identity(scryfall_id)
|
||||
if color_identity is None:
|
||||
return '?'
|
||||
# color identity is str of json array, convert to human readable string of list
|
||||
color_identity = [str(color) for color in color_identity]
|
||||
# if color identity is empty, return C for colorless
|
||||
if not color_identity:
|
||||
return 'C'
|
||||
# ensure order, W > U > B > R > G
|
||||
color_identity = sorted(color_identity, key=lambda x: ['W', 'U', 'B', 'R', 'G'].index(x))
|
||||
color_identity = ''.join(color_identity)
|
||||
else:
|
||||
color_identity = '?'
|
||||
return color_identity
|
||||
|
||||
async def _update_row_color_identity(self, db: Session, row: pd.Series) -> pd.Series:
|
||||
@@ -166,7 +176,7 @@ class PullSheetService(BaseService):
|
||||
'quantity': str(int(row['Quantity'])), # Convert to string for template
|
||||
'set': row['Set'],
|
||||
'rarity': row['Rarity'],
|
||||
'card_number': str(int(row['Number'])) if 'Number' in row and pd.notna(row['Number']) and '/' not in str(row['Number']) else str(row['Number']) if 'Number' in row and pd.notna(row['Number']) and '/' in str(row['Number']) else '',
|
||||
'card_number': str(int(float(row['Number']))) if 'Number' in row and pd.notna(row['Number']) and str(row['Number']).replace('.', '').isdigit() else str(row['Number']) if 'Number' in row and pd.notna(row['Number']) else '',
|
||||
'color_identity': row['Color Identity']
|
||||
})
|
||||
|
||||
|
@@ -35,7 +35,9 @@ class ServiceManager:
|
||||
'box': 'app.services.inventory_service.BoxService',
|
||||
'case': 'app.services.inventory_service.CaseService',
|
||||
'marketplace_listing': 'app.services.inventory_service.MarketplaceListingService',
|
||||
'scryfall': 'app.services.external_api.scryfall.scryfall_service.ScryfallService'
|
||||
'scryfall': 'app.services.external_api.scryfall.scryfall_service.ScryfallService',
|
||||
'tcgplayer_market_data': 'app.services.external_api.tcgplayer.tcgplayer_market_data_service.TCGPlayerMarketDataService',
|
||||
'inventory_label': 'app.services.inventory_label_service.InventoryLabelService'
|
||||
|
||||
}
|
||||
self._service_configs = {
|
||||
|
@@ -31,6 +31,7 @@
|
||||
<a href="/" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700">Orders</a>
|
||||
<a href="/manabox.html" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700">Manabox</a>
|
||||
<a href="/transactions.html" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700">Transactions</a>
|
||||
<a href="/inventory_labels.html" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700">Inventory Labels</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
118
app/static/inventory_labels.html
Normal file
118
app/static/inventory_labels.html
Normal file
@@ -0,0 +1,118 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Inventory Label Creator</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-gray-900 min-h-screen text-gray-100">
|
||||
<!-- Navigation Menu -->
|
||||
<nav class="bg-gray-800 shadow-sm">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<div class="flex items-center">
|
||||
<a href="/" class="text-xl font-bold text-gray-100">TCGPlayer Manager</a>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700">Orders</a>
|
||||
<a href="/manabox.html" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700">Manabox</a>
|
||||
<a href="/transactions.html" class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700">Transactions</a>
|
||||
<a href="/inventory_labels.html" class="px-3 py-2 rounded-md text-sm font-medium text-white bg-blue-600 rounded-lg">Inventory Labels</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="bg-gray-800 rounded-xl shadow-sm p-6 mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-100 mb-2">Inventory Label Creator</h1>
|
||||
<p class="text-gray-400">Create QR code labels for inventory items with optional UPC codes and metadata</p>
|
||||
</div>
|
||||
|
||||
<!-- Create Label Form -->
|
||||
<div class="bg-gray-800 rounded-xl shadow-sm p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-100 mb-6">Create New Label</h2>
|
||||
|
||||
<form id="createLabelForm" class="space-y-6">
|
||||
<!-- UPC Code -->
|
||||
<div>
|
||||
<label for="upc" class="block text-sm font-medium text-gray-300 mb-2">UPC Code (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="upc"
|
||||
name="upc"
|
||||
placeholder="Enter UPC code..."
|
||||
class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<p class="text-sm text-gray-400 mt-1">Enter a valid UPC-A, UPC-E, or EAN-13 code</p>
|
||||
</div>
|
||||
|
||||
<!-- Metadata Section -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<label class="block text-sm font-medium text-gray-300">Metadata (Optional)</label>
|
||||
<button
|
||||
type="button"
|
||||
onclick="addMetadataField()"
|
||||
class="px-3 py-1 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors text-sm"
|
||||
>
|
||||
Add Field
|
||||
</button>
|
||||
</div>
|
||||
<div id="metadataFields" class="space-y-3">
|
||||
<!-- Metadata fields will be added here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Print Option -->
|
||||
<div>
|
||||
<label class="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="printLabel"
|
||||
name="print"
|
||||
checked
|
||||
class="rounded border-gray-600 bg-gray-800 text-blue-600 focus:ring-blue-500"
|
||||
>
|
||||
<span class="text-gray-300">Print label immediately</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 transition-colors font-medium"
|
||||
>
|
||||
Create Label
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Recent Labels Section -->
|
||||
<div class="bg-gray-800 rounded-xl shadow-sm p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-100 mb-6">Recent Labels</h2>
|
||||
<div id="recentLabels" class="space-y-4">
|
||||
<!-- Recent labels will be displayed here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/inventory_labels.js"></script>
|
||||
</body>
|
||||
</html>
|
270
app/static/inventory_labels.js
Normal file
270
app/static/inventory_labels.js
Normal file
@@ -0,0 +1,270 @@
|
||||
// API base URL
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
// Show toast notification
|
||||
function showToast(message, type = 'success') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `fixed bottom-4 right-4 px-6 py-3 rounded-lg shadow-lg text-white ${
|
||||
type === 'success' ? 'bg-green-600' : 'bg-red-600'
|
||||
} transform translate-y-0 opacity-100 transition-all duration-300 z-50`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateY(100%)';
|
||||
toast.style.opacity = '0';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
function setLoading(isLoading) {
|
||||
const submitButton = document.querySelector('#createLabelForm button[type="submit"]');
|
||||
if (isLoading) {
|
||||
submitButton.disabled = true;
|
||||
submitButton.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
submitButton.textContent = 'Creating...';
|
||||
} else {
|
||||
submitButton.disabled = false;
|
||||
submitButton.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
submitButton.textContent = 'Create Label';
|
||||
}
|
||||
}
|
||||
|
||||
// Add metadata field
|
||||
function addMetadataField() {
|
||||
const metadataFields = document.getElementById('metadataFields');
|
||||
const fieldId = Date.now(); // Simple unique ID
|
||||
|
||||
const fieldDiv = document.createElement('div');
|
||||
fieldDiv.className = 'flex space-x-3 items-end';
|
||||
fieldDiv.innerHTML = `
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Key</label>
|
||||
<input
|
||||
type="text"
|
||||
name="metadata_key_${fieldId}"
|
||||
placeholder="e.g., product_name, condition, location..."
|
||||
class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Value</label>
|
||||
<input
|
||||
type="text"
|
||||
name="metadata_value_${fieldId}"
|
||||
placeholder="e.g., Lightning Bolt, NM, Shelf A1..."
|
||||
class="w-full rounded-lg border-gray-600 bg-gray-700 text-gray-100 focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick="removeMetadataField(this)"
|
||||
class="px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
`;
|
||||
|
||||
metadataFields.appendChild(fieldDiv);
|
||||
}
|
||||
|
||||
// Remove metadata field
|
||||
function removeMetadataField(button) {
|
||||
button.closest('div').remove();
|
||||
}
|
||||
|
||||
// Validate UPC code
|
||||
function validateUPC(upc) {
|
||||
if (!upc) return true; // Empty UPC is valid (optional field)
|
||||
|
||||
// Remove any non-digit characters
|
||||
const digitsOnly = upc.replace(/[^0-9]/g, '');
|
||||
|
||||
// Check for valid lengths
|
||||
if (digitsOnly.length === 12) {
|
||||
return validateUPCAChecksum(digitsOnly);
|
||||
} else if (digitsOnly.length === 8) {
|
||||
return validateUPCChecksum(digitsOnly);
|
||||
} else if (digitsOnly.length === 13) {
|
||||
return validateEAN13Checksum(digitsOnly);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate UPC-A checksum
|
||||
function validateUPCAChecksum(upc) {
|
||||
if (upc.length !== 12 || !/^\d+$/.test(upc)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (let i = 0; i < 11; i++) {
|
||||
const digit = parseInt(upc[i]);
|
||||
if (i % 2 === 0) { // Odd positions (0-indexed)
|
||||
total += digit * 3;
|
||||
} else { // Even positions
|
||||
total += digit;
|
||||
}
|
||||
}
|
||||
|
||||
const checksum = (10 - (total % 10)) % 10;
|
||||
return checksum === parseInt(upc[11]);
|
||||
}
|
||||
|
||||
// Validate UPC-E checksum
|
||||
function validateUPCChecksum(upc) {
|
||||
if (upc.length !== 8 || !/^\d+$/.test(upc)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const digit = parseInt(upc[i]);
|
||||
if (i % 2 === 0) { // Odd positions (0-indexed)
|
||||
total += digit * 3;
|
||||
} else { // Even positions
|
||||
total += digit;
|
||||
}
|
||||
}
|
||||
|
||||
const checksum = (10 - (total % 10)) % 10;
|
||||
return checksum === parseInt(upc[7]);
|
||||
}
|
||||
|
||||
// Validate EAN-13 checksum
|
||||
function validateEAN13Checksum(ean) {
|
||||
if (ean.length !== 13 || !/^\d+$/.test(ean)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const digit = parseInt(ean[i]);
|
||||
if (i % 2 === 0) { // Even positions (0-indexed)
|
||||
total += digit;
|
||||
} else { // Odd positions
|
||||
total += digit * 3;
|
||||
}
|
||||
}
|
||||
|
||||
const checksum = (10 - (total % 10)) % 10;
|
||||
return checksum === parseInt(ean[12]);
|
||||
}
|
||||
|
||||
// Collect form data
|
||||
function collectFormData() {
|
||||
const upc = document.getElementById('upc').value.trim();
|
||||
const print = document.getElementById('printLabel').checked;
|
||||
|
||||
// Collect metadata
|
||||
const metadata = [];
|
||||
const metadataFields = document.querySelectorAll('#metadataFields input[type="text"]');
|
||||
|
||||
for (let i = 0; i < metadataFields.length; i += 2) {
|
||||
const keyInput = metadataFields[i];
|
||||
const valueInput = metadataFields[i + 1];
|
||||
|
||||
if (keyInput && valueInput && keyInput.value.trim() && valueInput.value.trim()) {
|
||||
metadata.push({
|
||||
key: keyInput.value.trim(),
|
||||
value: valueInput.value.trim()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
upc: upc || null,
|
||||
metadata: metadata.length > 0 ? metadata : null,
|
||||
print: print
|
||||
};
|
||||
}
|
||||
|
||||
// Create inventory label
|
||||
async function createInventoryLabel(formData) {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/inventory-labels/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || 'Failed to create inventory label');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
showToast('Inventory label created successfully!', 'success');
|
||||
|
||||
// Reset form
|
||||
document.getElementById('createLabelForm').reset();
|
||||
document.getElementById('metadataFields').innerHTML = '';
|
||||
|
||||
// Optionally refresh recent labels
|
||||
// loadRecentLabels();
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
showToast('Error creating inventory label: ' + error.message, 'error');
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
async function handleFormSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = collectFormData();
|
||||
|
||||
// Validate UPC if provided
|
||||
if (formData.upc && !validateUPC(formData.upc)) {
|
||||
showToast('Invalid UPC code. Please enter a valid UPC-A, UPC-E, or EAN-13 code.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate metadata if provided
|
||||
if (formData.metadata) {
|
||||
for (const item of formData.metadata) {
|
||||
if (!item.key || !item.value) {
|
||||
showToast('All metadata fields must have both key and value.', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await createInventoryLabel(formData);
|
||||
} catch (error) {
|
||||
console.error('Error creating inventory label:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load recent labels (placeholder for future implementation)
|
||||
async function loadRecentLabels() {
|
||||
// This could be implemented to show recently created labels
|
||||
// For now, it's a placeholder
|
||||
const recentLabelsDiv = document.getElementById('recentLabels');
|
||||
recentLabelsDiv.innerHTML = '<p class="text-gray-400 text-center">No recent labels to display</p>';
|
||||
}
|
||||
|
||||
// Initialize page
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Set up form submission handler
|
||||
document.getElementById('createLabelForm').addEventListener('submit', handleFormSubmit);
|
||||
|
||||
// Load recent labels
|
||||
loadRecentLabels();
|
||||
|
||||
// Add initial metadata field
|
||||
addMetadataField();
|
||||
});
|
@@ -673,7 +673,7 @@ async function showOpenEventResultingItems(inventoryItemId, openEventId) {
|
||||
<tr>
|
||||
<td class="px-6 py-4 text-sm text-gray-300">${item.product.name}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-300">${item.product.group_name}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-300">$${item.product.market_price.toFixed(2)}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-300">${item.product.market_price !== null ? `$${item.product.market_price.toFixed(2)}` : 'N/A'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<button onclick="showInventoryItemDetails(${item.id})" class="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
|
||||
View Details
|
||||
|
Reference in New Issue
Block a user