order and api and more

This commit is contained in:
zman 2025-04-17 00:09:16 -04:00
parent 593e8960b7
commit 21408af48c
31 changed files with 1924 additions and 542 deletions

View File

@ -0,0 +1,126 @@
"""create_tcgplayer_order_tables
Revision ID: 6f2b3f870fdf
Revises: fix_foreign_key_issue
Create Date: 2025-04-16 20:19:01.698636
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSON
# revision identifiers, used by Alembic.
revision: str = '6f2b3f870fdf'
down_revision: Union[str, None] = 'fix_foreign_key_issue'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create tcgplayer_orders table
op.create_table(
'tcgplayer_orders',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('order_number', sa.String(), nullable=True),
sa.Column('order_created_at', sa.DateTime(), nullable=True),
sa.Column('status', sa.String(), nullable=True),
sa.Column('channel', sa.String(), nullable=True),
sa.Column('fulfillment', sa.String(), nullable=True),
sa.Column('seller_name', sa.String(), nullable=True),
sa.Column('buyer_name', sa.String(), nullable=True),
sa.Column('payment_type', sa.String(), nullable=True),
sa.Column('pickup_status', sa.String(), nullable=True),
sa.Column('shipping_type', sa.String(), nullable=True),
sa.Column('estimated_delivery_date', sa.DateTime(), nullable=True),
sa.Column('recipient_name', sa.String(), nullable=True),
sa.Column('address_line_1', sa.String(), nullable=True),
sa.Column('address_line_2', sa.String(), nullable=True),
sa.Column('city', sa.String(), nullable=True),
sa.Column('state', sa.String(), nullable=True),
sa.Column('zip_code', sa.String(), nullable=True),
sa.Column('country', sa.String(), nullable=True),
sa.Column('tracking_numbers', JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tcgplayer_orders_id'), 'tcgplayer_orders', ['id'], unique=False)
op.create_index(op.f('ix_tcgplayer_orders_order_number'), 'tcgplayer_orders', ['order_number'], unique=False)
# Create tcgplayer_order_transactions table
op.create_table(
'tcgplayer_order_transactions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('order_number', sa.String(), nullable=True),
sa.Column('product_amount', sa.Float(), nullable=True),
sa.Column('shipping_amount', sa.Float(), nullable=True),
sa.Column('gross_amount', sa.Float(), nullable=True),
sa.Column('fee_amount', sa.Float(), nullable=True),
sa.Column('net_amount', sa.Float(), nullable=True),
sa.Column('direct_fee_amount', sa.Float(), nullable=True),
sa.Column('taxes', JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tcgplayer_order_transactions_id'), 'tcgplayer_order_transactions', ['id'], unique=False)
op.create_index(op.f('ix_tcgplayer_order_transactions_order_number'), 'tcgplayer_order_transactions', ['order_number'], unique=False)
# Create tcgplayer_order_products table
op.create_table(
'tcgplayer_order_products',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('order_number', sa.String(), nullable=True),
sa.Column('product_name', sa.String(), nullable=True),
sa.Column('unit_price', sa.Float(), nullable=True),
sa.Column('extended_price', sa.Float(), nullable=True),
sa.Column('quantity', sa.Integer(), nullable=True),
sa.Column('url', sa.String(), nullable=True),
sa.Column('product_id', sa.String(), nullable=True),
sa.Column('sku_id', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tcgplayer_order_products_id'), 'tcgplayer_order_products', ['id'], unique=False)
op.create_index(op.f('ix_tcgplayer_order_products_order_number'), 'tcgplayer_order_products', ['order_number'], unique=False)
# Create tcgplayer_order_refunds table
op.create_table(
'tcgplayer_order_refunds',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('order_number', sa.String(), nullable=True),
sa.Column('refund_created_at', sa.DateTime(), nullable=True),
sa.Column('type', sa.String(), nullable=True),
sa.Column('amount', sa.Float(), nullable=True),
sa.Column('description', sa.String(), nullable=True),
sa.Column('origin', sa.String(), nullable=True),
sa.Column('shipping_amount', sa.Float(), nullable=True),
sa.Column('products', JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tcgplayer_order_refunds_id'), 'tcgplayer_order_refunds', ['id'], unique=False)
op.create_index(op.f('ix_tcgplayer_order_refunds_order_number'), 'tcgplayer_order_refunds', ['order_number'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_tcgplayer_order_refunds_order_number'), table_name='tcgplayer_order_refunds')
op.drop_index(op.f('ix_tcgplayer_order_refunds_id'), table_name='tcgplayer_order_refunds')
op.drop_table('tcgplayer_order_refunds')
op.drop_index(op.f('ix_tcgplayer_order_products_order_number'), table_name='tcgplayer_order_products')
op.drop_index(op.f('ix_tcgplayer_order_products_id'), table_name='tcgplayer_order_products')
op.drop_table('tcgplayer_order_products')
op.drop_index(op.f('ix_tcgplayer_order_transactions_order_number'), table_name='tcgplayer_order_transactions')
op.drop_index(op.f('ix_tcgplayer_order_transactions_id'), table_name='tcgplayer_order_transactions')
op.drop_table('tcgplayer_order_transactions')
op.drop_index(op.f('ix_tcgplayer_orders_order_number'), table_name='tcgplayer_orders')
op.drop_index(op.f('ix_tcgplayer_orders_id'), table_name='tcgplayer_orders')
op.drop_table('tcgplayer_orders')

40
app.log
View File

@ -1,7 +1,33 @@
2025-04-13 21:40:14,498 - INFO - app.main - Application starting up...
2025-04-13 21:40:14,714 - INFO - app.main - Database initialized successfully
2025-04-13 21:40:14,714 - INFO - app.main - TCGPlayer data initialized successfully
2025-04-13 21:40:14,715 - INFO - app.main - Scheduler started successfully
2025-04-13 21:40:17,147 - WARNING - app.services.scheduler.base_scheduler - Event loop already closed, skipping scheduler shutdown
2025-04-13 21:40:17,147 - INFO - app.main - Scheduler shut down
2025-04-13 21:40:17,148 - INFO - app.main - Database connection closed
2025-04-16 23:58:58,575 - INFO - app.main - Application starting up...
2025-04-16 23:58:58,622 - INFO - app.main - Database initialized successfully
2025-04-16 23:58:58,622 - INFO - app.services.service_manager - Service OrderManagementService registered
2025-04-16 23:58:58,622 - INFO - app.services.service_manager - Service TCGPlayerInventoryService registered
2025-04-16 23:58:58,622 - INFO - app.services.service_manager - Service LabelPrinterService registered
2025-04-16 23:58:58,622 - INFO - app.services.service_manager - Service RegularPrinterService registered
2025-04-16 23:58:58,625 - INFO - app.services.service_manager - Service AddressLabelService registered
2025-04-16 23:58:58,853 - INFO - app.services.service_manager - Service PullSheetService registered
2025-04-16 23:58:58,854 - INFO - app.services.service_manager - Service SetLabelService registered
2025-04-16 23:58:58,897 - INFO - app.services.service_manager - Service DataInitializationService registered
2025-04-16 23:58:58,914 - INFO - app.services.service_manager - Service SchedulerService registered
2025-04-16 23:58:58,914 - INFO - app.services.service_manager - All services initialized successfully
2025-04-16 23:58:58,914 - INFO - apscheduler.scheduler - Adding job tentatively -- it will be properly scheduled when the scheduler starts
2025-04-16 23:58:58,914 - INFO - app.services.scheduler.base_scheduler - Scheduled task process_tcgplayer_export to run every 86400 seconds
2025-04-16 23:58:58,914 - INFO - apscheduler.scheduler - Adding job tentatively -- it will be properly scheduled when the scheduler starts
2025-04-16 23:58:58,915 - INFO - app.services.scheduler.base_scheduler - Scheduled task update_open_orders_hourly to run every 3600 seconds
2025-04-16 23:58:58,915 - INFO - apscheduler.scheduler - Adding job tentatively -- it will be properly scheduled when the scheduler starts
2025-04-16 23:58:58,915 - INFO - app.services.scheduler.base_scheduler - Scheduled task update_all_orders_daily to run every 86400 seconds
2025-04-16 23:58:58,915 - INFO - apscheduler.scheduler - Added job "SchedulerService.process_tcgplayer_export" to job store "default"
2025-04-16 23:58:58,915 - INFO - apscheduler.scheduler - Added job "SchedulerService.update_open_orders_hourly" to job store "default"
2025-04-16 23:58:58,915 - INFO - apscheduler.scheduler - Added job "SchedulerService.update_all_orders_daily" to job store "default"
2025-04-16 23:58:58,915 - INFO - apscheduler.scheduler - Scheduler started
2025-04-16 23:58:58,915 - INFO - app.services.scheduler.base_scheduler - Scheduler started
2025-04-16 23:58:58,915 - INFO - app.services.scheduler.scheduler_service - All scheduled tasks started
2025-04-16 23:58:58,915 - INFO - app.main - Scheduler started successfully
2025-04-16 23:59:00,078 - INFO - app.services.external_api.tcgplayer.order_management_service - Getting orders from 0 to 25
2025-04-16 23:59:00,385 - INFO - app.services.external_api.base_external_service - Making request to https://order-management-api.tcgplayer.com/orders/search?api-version=2.0
2025-04-16 23:59:00,386 - INFO - app.services.external_api.tcgplayer.order_management_service - Got 25 orders
2025-04-16 23:59:00,386 - INFO - app.services.external_api.tcgplayer.order_management_service - Getting orders from 25 to 50
2025-04-16 23:59:00,494 - INFO - app.services.external_api.base_external_service - Making request to https://order-management-api.tcgplayer.com/orders/search?api-version=2.0
2025-04-16 23:59:00,494 - INFO - app.services.external_api.tcgplayer.order_management_service - Got 0 orders
2025-04-16 23:59:00,969 - INFO - app.services.external_api.base_external_service - Making request to https://order-management-api.tcgplayer.com/orders/pull-sheets/export?api-version=2.0
2025-04-16 23:59:01,208 - INFO - app.services.regular_printer_service - Print job 75 submitted to printer MFCL2750DW-3

View File

@ -24,35 +24,36 @@ body {
height: 100%;
position: relative;
box-sizing: border-box;
padding: 2mm;
padding: 1mm;
page-break-after: always;
}
/* Main address centered */
.address {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 85mm;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 88mm;
height: 100%;
text-align: center;
}
.recipient-name {
font-size: 10pt;
font-weight: bold;
font-size: 12pt;
margin-bottom: 1mm;
text-transform: uppercase;
}
.address-line {
font-size: 9pt;
line-height: 1.2;
font-size: 11pt;
line-height: 1.1;
margin-bottom: 0.5mm;
text-transform: uppercase;
}
.city-line {
font-size: 9pt;
font-weight: bold;
font-size: 11pt;
text-transform: uppercase;
}
</style>
@ -61,8 +62,7 @@ body {
<div class="label-container">
<div class="address">
<div class="recipient-name">{{ recipient_name }}</div>
<div class="address-line">{{ address_line1 }}</div>
{% if address_line2 %}<div class="address-line">{{ address_line2 }}</div>{% endif %}
<div class="address-line">{{ address_line1 }}{% if address_line2 %} {{ address_line2 }}{% endif %}</div>
<div class="city-line">{{ city }}, {{ state }} {{ zip_code }}</div>
</div>
</div>

View File

@ -80,15 +80,16 @@ tr:hover {
width: 150px;
}
.condition {
width: 80px;
}
.rarity {
width: 30px;
text-align: center;
}
.card-number {
width: 50px;
text-align: center;
}
.product-name {
width: 200px;
}
@ -114,20 +115,20 @@ tbody tr:hover {
<thead>
<tr>
<th class="product-name">Product Name</th>
<th class="condition">Condition</th>
<th class="quantity">Qty</th>
<th class="set">Set</th>
<th class="rarity">Rarity</th>
<th class="card-number">Card #</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr class="{{ 'foil' if 'Foil' in item.condition else '' }} {{ 'multiple' if item.quantity|int > 1 else '' }}">
<td class="product-name">{{ item.product_name }}</td>
<td class="condition">{{ item.condition }}</td>
<td class="quantity">{{ item.quantity }}</td>
<td class="set">{{ item.set }}</td>
<td class="rarity">{{ item.rarity }}</td>
<td class="card-number">{{ item.card_number }}</td>
</tr>
{% endfor %}
</tbody>

View File

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
@page {
size: 90mm 29mm;
margin: 0;
}
body {
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
}
.label {
width: 90mm;
height: 29mm;
display: flex;
align-items: flex-start;
padding: 1mm 2mm;
box-sizing: border-box;
}
.icon {
width: 12mm;
height: 12mm;
margin-right: 3mm;
margin-top: 1mm;
}
.info {
flex: 1;
padding-top: 1mm;
}
.name {
font-size: 12pt;
font-weight: bold;
margin-bottom: 2mm;
color: #000;
}
.code {
font-size: 11pt;
font-weight: bold;
margin-bottom: 2mm;
color: #000;
text-transform: uppercase;
}
.date {
font-size: 10pt;
color: #000;
}
</style>
</head>
<body>
<div class="label">
{% if icon_b64 %}
<img class="icon" src="data:image/svg+xml;base64,{{ icon_b64 }}" alt="{{ name }} icon">
{% endif %}
<div class="info">
<div class="name">{{ name }}</div>
<div class="code">{{ code }}</div>
<div class="date">{{ date.strftime('%B %Y') }}</div>
</div>
</div>
</body>
</html>

View File

@ -6,14 +6,9 @@ import logging
import os
from app.routes import routes
from app.db.database import init_db, SessionLocal
from app.services.scheduler.scheduler_service import SchedulerService
from app.services.data_initialization import DataInitializationService
from datetime import datetime
from app.services.external_api.tcgplayer.order_management_service import OrderManagementService
from app.services.address_label_service import AddressLabelService
from app.services.pull_sheet_service import PullSheetService
from app.services.label_printer_service import LabelPrinterService
from app.services.regular_printer_service import RegularPrinterService
from app.services.service_manager import ServiceManager
import logging
# Configure logging
log_file = "app.log"
if os.path.exists(log_file):
@ -39,76 +34,28 @@ root_logger.addHandler(file_handler)
logger = logging.getLogger(__name__)
logger.info("Application starting up...")
# Initialize scheduler service
scheduler_service = SchedulerService()
data_init_service = DataInitializationService()
order_management_service = OrderManagementService()
address_label_service = AddressLabelService()
pull_sheet_service = PullSheetService()
label_printer_service = LabelPrinterService(printer_api_url="http://192.168.1.110:8000")
regular_printer_service = RegularPrinterService(printer_name="MFCL2750DW-3")
# Initialize service manager
service_manager = ServiceManager()
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
init_db()
logger.info("Database initialized successfully")
# Initialize TCGPlayer data
db = SessionLocal()
try:
#await data_init_service.initialize_data(db, game_ids=[1, 3], init_archived_prices=False, archived_prices_start_date="2025-01-01", archived_prices_end_date=datetime.now().strftime("%Y-%m-%d"), init_categories=True, init_groups=True, init_products=True) # 1 = Magic, 3 = Pokemon
#orders = await order_management_service.get_orders(open_only=True)
#order_ids = [order.get("orderNumber") for order in orders]
# get only the first 3 order ids
#order_ids = order_ids[:3]
#logger.info(order_ids)
#packing_slip = await order_management_service.get_packing_slip(order_ids)
#packing_slip_file = await order_management_service.save_file(packing_slip, f"packing_slip_{datetime.now().strftime('%Y-%m-%d')}.pdf")
#await label_printer_service.print_file(packing_slip_file, label_size="dk1241", label_type="packing_slip")
#pull_sheet = await order_management_service.get_pull_sheet(order_ids)
#pull_sheet_file = await order_management_service.save_file(pull_sheet, f"pull_sheet_{datetime.now().strftime('%Y-%m-%d')}.csv")
#await regular_printer_service.print_file(pull_sheet_file)
#shipping_csv = await order_management_service.get_shipping_csv(order_ids)
#shipping_csv_file = await order_management_service.save_file(shipping_csv, f"shipping_csv_{datetime.now().strftime('%Y-%m-%d')}.csv")
# Wait for the file to be saved before generating labels
#if not shipping_csv_file:
# logger.error("Failed to save shipping CSV file")
# return
#shipping_labels_dk1241 = address_label_service.generate_labels_from_csv(shipping_csv_file, label_type="dk1241")
#if not shipping_labels_dk1241:
# logger.error("Failed to generate shipping labels")
# return
#for label in shipping_labels_dk1241:
# if not label:
# logger.error("Empty label path in shipping labels list")
# continue
# await label_printer_service.print_file(label, label_size="dk1241", label_type="address_label")
logger.info("TCGPlayer data initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize TCGPlayer data: {str(e)}")
finally:
db.close()
# Initialize all services
await service_manager.initialize_services()
# Start the scheduler
#await scheduler_service.start_scheduled_tasks()
#await scheduler_service.process_tcgplayer_export(export_type="live", use_cache=True)
scheduler = service_manager.get_service('scheduler')
await scheduler.start_scheduled_tasks()
logger.info("Scheduler started successfully")
yield
# Shutdown
await scheduler_service.scheduler.shutdown()
await data_init_service.close()
logger.info("Scheduler shut down")
logger.info("Database connection closed")
await service_manager.cleanup_services()
logger.info("All services cleaned up successfully")
app = FastAPI(
title="CCR Cards Management API",

View File

@ -1,25 +0,0 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from datetime import datetime
from app.db.database import Base
class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True)
customer_name = Column(String, index=True)
customer_email = Column(String)
total_amount = Column(Float)
status = Column(String, default="pending") # pending, processing, shipped, delivered, cancelled
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class OrderCard(Base):
__tablename__ = "order_cards"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"))
card_id = Column(Integer, ForeignKey("cards.id"))
quantity = Column(Integer, default=1)
price_at_time = Column(Float) # Price of the card when ordered
created_at = Column(DateTime, default=datetime.utcnow)

View File

@ -0,0 +1,78 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, JSON
from sqlalchemy.orm import relationship
from datetime import datetime, UTC
from app.db.database import Base
class TCGPlayerOrder(Base):
__tablename__ = "tcgplayer_orders"
id = Column(Integer, primary_key=True, index=True)
order_number = Column(String, index=True)
order_created_at = Column(DateTime)
status = Column(String)
channel = Column(String)
fulfillment = Column(String)
seller_name = Column(String)
buyer_name = Column(String)
payment_type = Column(String)
pickup_status = Column(String)
shipping_type = Column(String)
estimated_delivery_date = Column(DateTime)
recipient_name = Column(String)
address_line_1 = Column(String)
address_line_2 = Column(String)
city = Column(String)
state = Column(String)
zip_code = Column(String)
country = Column(String)
tracking_numbers = Column(JSON)
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
class TCGPlayerOrderTransaction(Base):
__tablename__ = "tcgplayer_order_transactions"
id = Column(Integer, primary_key=True, index=True)
order_number = Column(String, index=True)
product_amount = Column(Float)
shipping_amount = Column(Float)
gross_amount = Column(Float)
fee_amount = Column(Float)
net_amount = Column(Float)
direct_fee_amount = Column(Float)
taxes = Column(JSON)
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
class TCGPlayerOrderProduct(Base):
__tablename__ = "tcgplayer_order_products"
id = Column(Integer, primary_key=True, index=True)
order_number = Column(String, index=True)
product_name = Column(String)
unit_price = Column(Float)
extended_price = Column(Float)
quantity = Column(Integer)
url = Column(String)
product_id = Column(String)
sku_id = Column(String)
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
class TCGPlayerOrderRefund(Base):
__tablename__ = "tcgplayer_order_refunds"
id = Column(Integer, primary_key=True, index=True)
order_number = Column(String, index=True)
refund_created_at = Column(DateTime)
type = Column(String)
amount = Column(Float)
type = Column(String)
description = Column(String)
origin = Column(String)
shipping_amount = Column(Float)
products = Column(JSON)
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))

214
app/routes/order_routes.py Normal file
View File

@ -0,0 +1,214 @@
from fastapi import APIRouter, HTTPException, Depends, Query
from app.services.external_api.tcgplayer.order_management_service import OrderManagementService
from app.services.label_printer_service import LabelPrinterService
from app.services.regular_printer_service import RegularPrinterService
from app.services.address_label_service import AddressLabelService
from typing import List, Optional, Literal
from datetime import datetime
from pydantic import BaseModel, Field
from enum import Enum
from app.schemas.tcgplayer import TCGPlayerAPIOrderSummary, TCGPlayerAPIOrder
from app.services.service_manager import ServiceManager
class SearchRange(str, Enum):
LAST_WEEK = "LastWeek"
LAST_MONTH = "LastMonth"
LAST_THREE_MONTHS = "LastThreeMonths"
LAST_FOUR_MONTHS = "LastFourMonths"
LAST_TWO_YEARS = "LastTwoYears"
class LabelType(str, Enum):
DK1201 = "dk1201"
DK1241 = "dk1241"
# Initialize service manager
service_manager = ServiceManager()
# Initialize router
router = APIRouter(prefix="/orders")
@router.get("/", response_model=List[TCGPlayerAPIOrderSummary])
async def get_orders(
search_range: SearchRange = SearchRange.LAST_THREE_MONTHS,
open_only: bool = False
) -> List[TCGPlayerAPIOrderSummary]:
"""
Retrieve orders from TCGPlayer based on search criteria.
Args:
search_range: Time range to search for orders
open_only: Whether to only return open orders
Returns:
List of orders matching the search criteria
"""
try:
order_management = service_manager.get_service('order_management')
orders = await order_management.get_orders(search_range, open_only)
return orders
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to fetch orders: {str(e)}")
@router.get("/{order_id}", response_model=TCGPlayerAPIOrder)
async def get_order(order_id: str) -> TCGPlayerAPIOrder:
"""
Retrieve a specific order by ID.
Args:
order_id: The TCGPlayer order number
Returns:
The requested order details
"""
try:
order_management = service_manager.get_service('order_management')
order = await order_management.get_order(order_id)
if not order:
raise HTTPException(status_code=404, detail=f"Order {order_id} not found")
return order
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to fetch order: {str(e)}")
@router.post("/generate-pull-sheets")
async def generate_pull_sheets(
order_ids: Optional[List[str]] = None,
all_open_orders: bool = False
) -> dict:
"""
Generate and print pull sheets for the specified orders.
Args:
order_ids: List of TCGPlayer order numbers (optional if all_open_orders is True)
all_open_orders: If True, generate pull sheets for all orders (ignores order_ids)
Returns:
Success status of the operation
"""
try:
order_management = service_manager.get_service('order_management')
if not all_open_orders and not order_ids:
raise HTTPException(
status_code=400,
detail="Either order_ids must be provided or all_open_orders must be True"
)
if all_open_orders:
order_ids = await order_management.get_order_ids(search_range="LastWeek", open_only=True)
pull_sheet = await order_management.get_pull_sheet(order_ids)
pull_sheet_file = await order_management.save_file(
pull_sheet,
f"pull_sheet_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.csv"
)
pull_sheet_service = service_manager.get_service('pull_sheet')
pull_sheet_pdf = await pull_sheet_service.generate_pull_sheet_pdf(pull_sheet_file)
regular_printer = service_manager.get_service('regular_printer')
success = await regular_printer.print_file(pull_sheet_pdf)
return {"success": success}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to generate pull sheet: {str(e)}")
@router.post("/generate-packing-slips")
async def generate_packing_slips(
order_ids: Optional[List[str]] = None,
all_open_orders: bool = False
) -> dict:
"""
Generate and print packing slips for the specified orders.
Args:
order_ids: List of TCGPlayer order numbers (optional if all_open_orders is True)
all_open_orders: If True, generate packing slips for all orders (ignores order_ids)
Returns:
Success status of the operation
"""
try:
if not all_open_orders and not order_ids:
raise HTTPException(
status_code=400,
detail="Either order_ids must be provided or all_open_orders must be True"
)
# TODO: Add logic to fetch all orders when all_open_orders is True
if all_open_orders:
order_management = service_manager.get_service('order_management')
order_ids = await order_management.get_order_ids(search_range="LastWeek", open_only=True)
packing_slip = await order_management.get_packing_slip(order_ids)
packing_slip_file = await order_management.save_file(
packing_slip,
f"packing_slip_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.pdf"
)
label_printer = service_manager.get_service('label_printer')
success = await label_printer.print_file(
packing_slip_file,
label_size="dk1241",
label_type="packing_slip"
)
return {"success": success}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to generate packing slip: {str(e)}")
@router.post("/generate-address-labels")
async def generate_address_labels(
order_ids: Optional[List[str]] = None,
all_open_orders: bool = False,
label_type: LabelType = LabelType.DK1201
) -> dict:
"""
Generate and print address labels for the specified orders.
Args:
order_ids: List of TCGPlayer order numbers (optional if all_open_orders is True)
all_open_orders: If True, generate address labels for all orders (ignores order_ids)
label_type: Type of label to generate (dk1201 or dk1241)
Returns:
Success status of the operation
"""
try:
order_management = service_manager.get_service('order_management')
if not all_open_orders and not order_ids:
raise HTTPException(
status_code=400,
detail="Either order_ids must be provided or all_open_orders must be True"
)
if all_open_orders:
order_ids = await order_management.get_order_ids(search_range="LastWeek", open_only=True)
shipping_csv = await order_management.get_shipping_csv(order_ids)
shipping_csv_file = await order_management.save_file(
shipping_csv,
f"shipping_csv_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.csv"
)
# Generate PDFs
address_label = service_manager.get_service('address_label')
pdf_files = await address_label.generate_labels_from_csv(
shipping_csv_file,
label_type=label_type
)
# Print each PDF
label_printer = service_manager.get_service('label_printer')
for pdf_file in pdf_files:
success = await label_printer.print_file(
pdf_file,
label_size=label_type,
label_type="address_label"
)
if not success:
raise HTTPException(
status_code=500,
detail=f"Failed to print address label for file {pdf_file}"
)
return {"success": True, "message": "Address labels generated and printed successfully"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to generate address labels: {str(e)}")

View File

@ -8,18 +8,16 @@ from app.schemas.box import BoxCreate, BoxUpdate, BoxDelete, BoxList, OpenBoxCre
from app.models.game import Game as GameModel
from app.schemas.game import GameCreate, GameUpdate, GameDelete, GameList, GameInDB
from app.models.card import Card as CardModel
from app.schemas.card import CardCreate, CardUpdate, CardDelete, CardList, CardInDB
from app.services import CardService, OrderService
from app.services.file_processing_service import FileProcessingService
from app.services.external_api.tcgplayer.tcgplayer_inventory_service import TCGPlayerInventoryService
from app.routes.set_label_routes import router as set_label_router
from app.routes.order_routes import router as order_router
router = APIRouter(prefix="/api")
# Initialize services
card_service = CardService()
order_service = OrderService()
file_processing_service = FileProcessingService()
tcgplayer_inventory_service = TCGPlayerInventoryService()
# Include set label routes
router.include_router(set_label_router)
# Include order routes
router.include_router(order_router)
# ============================================================================
# Health Check & Root Endpoints
@ -32,80 +30,6 @@ async def root():
async def health():
return {"status": "ok"}
# ============================================================================
# Card Management Endpoints
# ============================================================================
@router.get("/cards", response_model=CardList)
async def get_cards(
db: Session = Depends(get_db),
page: int = 1,
limit: int = 10,
type: str = None,
id: int = None
):
skip = (page - 1) * limit
cards = card_service.get_all(db, skip=skip, limit=limit)
total = db.query(CardModel).count()
return {
"cards": cards,
"total": total,
"page": page,
"limit": limit
}
@router.post("/cards", response_model=CardInDB)
async def create_card(card: CardCreate, db: Session = Depends(get_db)):
try:
created_card = card_service.create(db, card.dict())
return created_card
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/cards/{card_id}", response_model=CardInDB)
async def update_card(card_id: int, card: CardUpdate, db: Session = Depends(get_db)):
db_card = card_service.get(db, card_id)
if not db_card:
raise HTTPException(status_code=404, detail="Card not found")
try:
updated_card = card_service.update(db, db_card, card.dict(exclude_unset=True))
return updated_card
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/cards/{card_id}", response_model=CardDelete)
async def delete_card(card_id: int, db: Session = Depends(get_db)):
success = card_service.delete(db, card_id)
if not success:
raise HTTPException(status_code=404, detail="Card not found")
return {"message": "Card deleted successfully"}
# ============================================================================
# Order Management Endpoints
# ============================================================================
@router.post("/orders")
async def create_order(order_data: dict, card_ids: list[int], db: Session = Depends(get_db)):
try:
order = order_service.create_order_with_cards(db, order_data, card_ids)
return order
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/orders")
async def get_orders(
db: Session = Depends(get_db),
page: int = 1,
limit: int = 10
):
skip = (page - 1) * limit
orders = order_service.get_orders_with_cards(db, skip=skip, limit=limit)
return {
"orders": orders,
"page": page,
"limit": limit
}
# ============================================================================
# File Management Endpoints
# ============================================================================

View File

@ -0,0 +1,52 @@
from fastapi import APIRouter, HTTPException, Depends
from app.services.set_label_service import SetLabelService
from app.services.label_printer_service import LabelPrinterService
from typing import List, Optional
import asyncio
from app.db.database import get_db
from sqlalchemy.orm import Session
from pydantic import BaseModel
class SetLabelRequest(BaseModel):
sets: List[str]
router = APIRouter(prefix="/set-labels")
set_label_service = SetLabelService()
label_printer_service = LabelPrinterService(printer_api_url="http://192.168.1.110:8000")
@router.post("/generate")
async def generate_set_labels(request: SetLabelRequest):
"""
Generate PDF labels for the specified MTG sets.
Args:
request: Request body containing list of set codes to generate labels for
Returns:
Message indicating success or failure
"""
try:
set_pdfs = await set_label_service.generate_labels(request.sets)
for set_pdf in set_pdfs:
success = await label_printer_service.print_file(set_pdf, label_size="dk1201", label_type="set_label")
if not success:
raise HTTPException(status_code=500, detail=f"Failed to print")
return {
"message": "Labels printed successfully"
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/available-sets")
async def get_available_sets(db: Session = Depends(get_db)):
"""
Get a list of available MTG sets that can be used for label generation.
Returns:
List of set codes and their names
"""
try:
sets = await set_label_service.get_available_sets(db)
return sets
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

182
app/schemas/tcgplayer.py Normal file
View File

@ -0,0 +1,182 @@
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, Field
# API Response Schemas (matching TCGPlayer API structure)
class TCGPlayerAPIOrderProduct(BaseModel):
name: str
unitPrice: float
extendedPrice: float
quantity: int
url: str
productId: str
skuId: str
class TCGPlayerAPIOrderTax(BaseModel):
code: str
amount: float
class TCGPlayerAPIOrderTransaction(BaseModel):
productAmount: float
shippingAmount: float
grossAmount: float
feeAmount: float
netAmount: float
directFeeAmount: float
taxes: List[TCGPlayerAPIOrderTax]
class TCGPlayerAPIOrderRefund(BaseModel):
createdAt: datetime
type: str
amount: float
description: Optional[str] = None
origin: str
shippingAmount: float
products: List[dict]
class TCGPlayerAPIOrderShippingAddress(BaseModel):
recipientName: str
addressOne: str
addressTwo: Optional[str] = None
city: str
territory: str
country: str
postalCode: str
class TCGPlayerAPIOrderTrackingNumber(BaseModel):
createdAt: str
carrier: str
trackingNumber: str
status: str
class TCGPlayerAPIOrder(BaseModel):
createdAt: str
status: str
orderChannel: str
orderFulfillment: str
orderNumber: str
sellerName: str
buyerName: str
paymentType: str
pickupStatus: str
shippingType: str
estimatedDeliveryDate: datetime
transaction: TCGPlayerAPIOrderTransaction
shippingAddress: TCGPlayerAPIOrderShippingAddress
products: List[TCGPlayerAPIOrderProduct]
refunds: Optional[List[TCGPlayerAPIOrderRefund]]
refundStatus: str
trackingNumbers: List[TCGPlayerAPIOrderTrackingNumber]
allowedActions: List[str]
class TCGPlayerAPIOrderSummary(BaseModel):
orderNumber: str
orderDate: datetime
orderChannel: str
orderStatus: str
buyerName: str
shippingType: str
productAmount: float
shippingAmount: float
totalAmount: float
buyerPaid: bool
orderFulfillment: str
class TCGPlayerAPIOrderSearchResponse(BaseModel):
totalOrders: int
orders: List[TCGPlayerAPIOrderSummary]
# Database Schemas (matching your internal structure)
class TCGPlayerOrderBase(BaseModel):
order_number: str
order_created_at: datetime
status: str
channel: str
fulfillment: str
seller_name: str
buyer_name: str
payment_type: str
pickup_status: str
shipping_type: str
estimated_delivery_date: Optional[datetime]
recipient_name: str
address_line_1: str
address_line_2: Optional[str]
city: str
state: str
zip_code: str
country: str
tracking_numbers: List[str]
class TCGPlayerOrderCreate(TCGPlayerOrderBase):
pass
class TCGPlayerOrder(TCGPlayerOrderBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TCGPlayerOrderTransactionBase(BaseModel):
order_number: str
product_amount: float
shipping_amount: float
gross_amount: float
fee_amount: float
net_amount: float
direct_fee_amount: float
taxes: List[dict]
class TCGPlayerOrderTransactionCreate(TCGPlayerOrderTransactionBase):
pass
class TCGPlayerOrderTransaction(TCGPlayerOrderTransactionBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TCGPlayerOrderProductBase(BaseModel):
order_number: str
product_name: str
unit_price: float
extended_price: float
quantity: int
url: str
product_id: str
sku_id: str
class TCGPlayerOrderProductCreate(TCGPlayerOrderProductBase):
pass
class TCGPlayerOrderProduct(TCGPlayerOrderProductBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TCGPlayerOrderRefundBase(BaseModel):
order_number: str
refund_created_at: datetime
type: str
amount: float
description: Optional[str] = None
origin: str
shipping_amount: float
products: List[dict]
class TCGPlayerOrderRefundCreate(TCGPlayerOrderRefundBase):
pass
class TCGPlayerOrderRefund(TCGPlayerOrderRefundBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

View File

@ -1,15 +1,11 @@
from app.services.base_service import BaseService
from app.services.card_service import CardService
from app.services.order_service import OrderService
from app.services.service_manager import ServiceManager
from app.services.file_processing_service import FileProcessingService
from app.services.inventory_service import InventoryService
from app.services.service_registry import ServiceRegistry
__all__ = [
'BaseService',
'CardService',
'OrderService',
'ServiceManager',
'FileProcessingService',
'InventoryService',
'ServiceRegistry'
'InventoryService'
]

View File

@ -4,6 +4,11 @@ import os
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from weasyprint import HTML
import logging
import asyncio
from concurrent.futures import ThreadPoolExecutor
logger = logging.getLogger(__name__)
class AddressLabelService:
def __init__(self):
@ -16,8 +21,9 @@ class AddressLabelService:
self.return_address_path = "file://" + os.path.abspath("app/data/assets/images/ccrcardsaddress.png")
self.output_dir = "app/data/cache/tcgplayer/address_labels/"
os.makedirs(self.output_dir, exist_ok=True)
self.executor = ThreadPoolExecutor()
def generate_labels_from_csv(self, csv_path: str, label_type: Literal["dk1201", "dk1241"]) -> List[str]:
async def generate_labels_from_csv(self, csv_path: str, label_type: Literal["dk1201", "dk1241"]) -> List[str]:
"""Generate address labels from a CSV file and save them as PDFs.
Args:
@ -29,17 +35,30 @@ class AddressLabelService:
"""
generated_files = []
with open(csv_path, 'r') as csvfile:
reader = csv.DictReader(csvfile)
for row in reader:
# Generate label for each row
pdf_path = self._generate_single_label(row, label_type)
if pdf_path:
generated_files.append(str(pdf_path))
# Read CSV file in a thread pool
loop = asyncio.get_event_loop()
rows = await loop.run_in_executor(self.executor, self._read_csv, csv_path)
for row in rows:
# if value of Value Of Products is greater than 50, skip
if row.get('Value Of Products') and float(row['Value Of Products']) > 50:
logger.info(f"Skipping order {row.get('Order #')} because value of products is greater than 50")
continue
# Generate label for each row
pdf_path = await self._generate_single_label(row, label_type)
if pdf_path:
generated_files.append(str(pdf_path))
return generated_files
def _generate_single_label(self, row: Dict[str, str], label_type: Literal["dk1201", "dk1241"]) -> Optional[str]:
def _read_csv(self, csv_path: str) -> List[Dict[str, str]]:
"""Read CSV file and return list of rows."""
with open(csv_path, 'r') as csvfile:
reader = csv.DictReader(csvfile)
return list(reader)
async def _generate_single_label(self, row: Dict[str, str], label_type: Literal["dk1201", "dk1241"]) -> Optional[str]:
"""Generate a single address label PDF.
Args:
@ -67,11 +86,15 @@ class AddressLabelService:
# Render HTML
html_content = self.templates[label_type].render(**template_data)
# Generate PDF
# Generate PDF in a thread pool
loop = asyncio.get_event_loop()
pdf_path = self.output_dir + f"{row['Order #']}_{label_type}.pdf"
HTML(string=html_content).write_pdf(str(pdf_path))
await loop.run_in_executor(
self.executor,
lambda: HTML(string=html_content).write_pdf(str(pdf_path))
)
return pdf_path
except Exception as e:
print(f"Error generating label for order {row.get('Order #', 'unknown')}: {str(e)}")
logger.error(f"Error generating label for order {row.get('Order #', 'unknown')}: {str(e)}")
return None

View File

@ -1,15 +1,12 @@
from typing import Type, TypeVar, Generic, List, Optional, Any
from sqlalchemy.orm import Session
from app.db.database import Base
from app.services.service_registry import ServiceRegistry
T = TypeVar('T')
class BaseService(Generic[T]):
def __init__(self, model: Type[T]):
self.model = model
# Register the service instance
ServiceRegistry.register(self.__class__.__name__, self)
def get(self, db: Session, id: int) -> Optional[T]:
return db.query(self.model).filter(self.model.id == id).first()
@ -39,7 +36,4 @@ class BaseService(Generic[T]):
db.delete(obj)
db.commit()
return True
return False
def get_service(self, service_name: str) -> Any:
return ServiceRegistry.get(service_name)
return False

View File

@ -1,7 +1,6 @@
from typing import Any, Dict, Optional, Union
import aiohttp
import logging
from app.services.service_registry import ServiceRegistry
import json
logger = logging.getLogger(__name__)
@ -11,8 +10,6 @@ class BaseExternalService:
self.base_url = base_url
self.api_key = api_key
self.session = None
# Register the service instance
ServiceRegistry.register(self.__class__.__name__, self)
async def _get_session(self) -> aiohttp.ClientSession:
if self.session is None or self.session.closed:
@ -72,5 +69,8 @@ class BaseExternalService:
raise
async def close(self):
"""Close the aiohttp session if it exists"""
if self.session and not self.session.closed:
await self.session.close()
await self.session.close()
self.session = None
logger.info(f"Closed session for {self.__class__.__name__}")

View File

@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from datetime import datetime
from app.models.mtgjson_card import MTGJSONCard
from app.models.mtgjson_sku import MTGJSONSKU
from app.db.database import get_db, transaction
class MTGJSONService:
def __init__(self, cache_dir: str = "app/data/cache/mtgjson", batch_size: int = 1000):
@ -84,72 +85,67 @@ class MTGJSONService:
for key, value in data["data"].items():
yield {"type": "item", "data": {key: value}}
async def _process_batch(self, db: Session, items: list, model_class, commit: bool = True) -> int:
async def _process_batch(self, db: Session, items: list, model_class) -> int:
"""Process a batch of items and add them to the database"""
processed = 0
for item in items:
if model_class == MTGJSONCard:
# Check if card already exists
existing_card = db.query(MTGJSONCard).filter(MTGJSONCard.card_id == item["card_id"]).first()
if existing_card:
continue
new_item = MTGJSONCard(
card_id=item["card_id"],
name=item["name"],
set_code=item["set_code"],
uuid=item["uuid"],
abu_id=item.get("abu_id"),
card_kingdom_etched_id=item.get("card_kingdom_etched_id"),
card_kingdom_foil_id=item.get("card_kingdom_foil_id"),
card_kingdom_id=item.get("card_kingdom_id"),
cardsphere_id=item.get("cardsphere_id"),
cardsphere_foil_id=item.get("cardsphere_foil_id"),
cardtrader_id=item.get("cardtrader_id"),
csi_id=item.get("csi_id"),
mcm_id=item.get("mcm_id"),
mcm_meta_id=item.get("mcm_meta_id"),
miniaturemarket_id=item.get("miniaturemarket_id"),
mtg_arena_id=item.get("mtg_arena_id"),
mtgjson_foil_version_id=item.get("mtgjson_foil_version_id"),
mtgjson_non_foil_version_id=item.get("mtgjson_non_foil_version_id"),
mtgjson_v4_id=item.get("mtgjson_v4_id"),
mtgo_foil_id=item.get("mtgo_foil_id"),
mtgo_id=item.get("mtgo_id"),
multiverse_id=item.get("multiverse_id"),
scg_id=item.get("scg_id"),
scryfall_id=item.get("scryfall_id"),
scryfall_card_back_id=item.get("scryfall_card_back_id"),
scryfall_oracle_id=item.get("scryfall_oracle_id"),
scryfall_illustration_id=item.get("scryfall_illustration_id"),
tcgplayer_product_id=item.get("tcgplayer_product_id"),
tcgplayer_etched_product_id=item.get("tcgplayer_etched_product_id"),
tnt_id=item.get("tnt_id")
)
else: # MTGJSONSKU
# Check if SKU already exists
existing_sku = db.query(MTGJSONSKU).filter(MTGJSONSKU.sku_id == item["sku_id"]).first()
if existing_sku:
continue
new_item = MTGJSONSKU(
sku_id=str(item["sku_id"]),
product_id=str(item["product_id"]),
condition=item["condition"],
finish=item["finish"],
language=item["language"],
printing=item["printing"],
card_id=item["card_id"]
)
db.add(new_item)
processed += 1
if commit:
try:
db.commit()
except Exception as e:
db.rollback()
raise e
with transaction(db):
for item in items:
if model_class == MTGJSONCard:
# Check if card already exists
existing_card = db.query(MTGJSONCard).filter(MTGJSONCard.card_id == item["card_id"]).first()
if existing_card:
continue
new_item = MTGJSONCard(
card_id=item["card_id"],
name=item["name"],
set_code=item["set_code"],
uuid=item["uuid"],
abu_id=item.get("abu_id"),
card_kingdom_etched_id=item.get("card_kingdom_etched_id"),
card_kingdom_foil_id=item.get("card_kingdom_foil_id"),
card_kingdom_id=item.get("card_kingdom_id"),
cardsphere_id=item.get("cardsphere_id"),
cardsphere_foil_id=item.get("cardsphere_foil_id"),
cardtrader_id=item.get("cardtrader_id"),
csi_id=item.get("csi_id"),
mcm_id=item.get("mcm_id"),
mcm_meta_id=item.get("mcm_meta_id"),
miniaturemarket_id=item.get("miniaturemarket_id"),
mtg_arena_id=item.get("mtg_arena_id"),
mtgjson_foil_version_id=item.get("mtgjson_foil_version_id"),
mtgjson_non_foil_version_id=item.get("mtgjson_non_foil_version_id"),
mtgjson_v4_id=item.get("mtgjson_v4_id"),
mtgo_foil_id=item.get("mtgo_foil_id"),
mtgo_id=item.get("mtgo_id"),
multiverse_id=item.get("multiverse_id"),
scg_id=item.get("scg_id"),
scryfall_id=item.get("scryfall_id"),
scryfall_card_back_id=item.get("scryfall_card_back_id"),
scryfall_oracle_id=item.get("scryfall_oracle_id"),
scryfall_illustration_id=item.get("scryfall_illustration_id"),
tcgplayer_product_id=item.get("tcgplayer_product_id"),
tcgplayer_etched_product_id=item.get("tcgplayer_etched_product_id"),
tnt_id=item.get("tnt_id")
)
else: # MTGJSONSKU
# Check if SKU already exists
existing_sku = db.query(MTGJSONSKU).filter(MTGJSONSKU.sku_id == item["sku_id"]).first()
if existing_sku:
continue
new_item = MTGJSONSKU(
sku_id=str(item["sku_id"]),
product_id=str(item["product_id"]),
condition=item["condition"],
finish=item["finish"],
language=item["language"],
printing=item["printing"],
card_id=item["card_id"]
)
db.add(new_item)
processed += 1
return processed
async def download_and_process_identifiers(self, db: Session) -> Dict[str, int]:

View File

@ -6,6 +6,7 @@ from app.services.external_api.base_external_service import BaseExternalService
from app.models.tcgplayer_group import TCGPlayerGroup
from app.models.tcgplayer_product import TCGPlayerProduct
from app.models.tcgplayer_category import TCGPlayerCategory
from app.db.database import get_db, transaction
from sqlalchemy.orm import Session
import py7zr
import os
@ -100,42 +101,41 @@ class TCGCSVService(BaseExternalService):
groups = response.get("results", [])
synced_groups = []
with transaction(db):
for group_data in groups:
# Convert string dates to datetime objects
published_on = datetime.fromisoformat(group_data["publishedOn"].replace("Z", "+00:00")) if group_data.get("publishedOn") else None
modified_on = datetime.fromisoformat(group_data["modifiedOn"].replace("Z", "+00:00")) if group_data.get("modifiedOn") else None
for group_data in groups:
# Convert string dates to datetime objects
published_on = datetime.fromisoformat(group_data["publishedOn"].replace("Z", "+00:00")) if group_data.get("publishedOn") else None
modified_on = datetime.fromisoformat(group_data["modifiedOn"].replace("Z", "+00:00")) if group_data.get("modifiedOn") else None
# Check if group already exists
existing_group = db.query(TCGPlayerGroup).filter(TCGPlayerGroup.group_id == group_data["groupId"]).first()
if existing_group:
# Update existing group
for key, value in {
"name": group_data["name"],
"abbreviation": group_data.get("abbreviation"),
"is_supplemental": group_data.get("isSupplemental", False),
"published_on": published_on,
"modified_on": modified_on,
"category_id": group_data.get("categoryId")
}.items():
setattr(existing_group, key, value)
synced_groups.append(existing_group)
else:
# Create new group
new_group = TCGPlayerGroup(
group_id=group_data["groupId"],
name=group_data["name"],
abbreviation=group_data.get("abbreviation"),
is_supplemental=group_data.get("isSupplemental", False),
published_on=published_on,
modified_on=modified_on,
category_id=group_data.get("categoryId")
)
db.add(new_group)
synced_groups.append(new_group)
# Check if group already exists
existing_group = db.query(TCGPlayerGroup).filter(TCGPlayerGroup.group_id == group_data["groupId"]).first()
if existing_group:
# Update existing group
for key, value in {
"name": group_data["name"],
"abbreviation": group_data.get("abbreviation"),
"is_supplemental": group_data.get("isSupplemental", False),
"published_on": published_on,
"modified_on": modified_on,
"category_id": group_data.get("categoryId")
}.items():
setattr(existing_group, key, value)
synced_groups.append(existing_group)
else:
# Create new group
new_group = TCGPlayerGroup(
group_id=group_data["groupId"],
name=group_data["name"],
abbreviation=group_data.get("abbreviation"),
is_supplemental=group_data.get("isSupplemental", False),
published_on=published_on,
modified_on=modified_on,
category_id=group_data.get("categoryId")
)
db.add(new_group)
synced_groups.append(new_group)
db.commit()
return synced_groups
async def sync_products_to_db(self, db: Session, game_id: int, group_id: int) -> List[TCGPlayerProduct]:
@ -178,29 +178,29 @@ class TCGCSVService(BaseExternalService):
synced_products.append(existing_product)
else:
# Create new product
new_product = TCGPlayerProduct(
product_id=int(product_data["productId"]),
name=product_data["name"],
clean_name=product_data.get("cleanName"),
image_url=product_data.get("imageUrl"),
category_id=int(product_data["categoryId"]),
group_id=int(product_data["groupId"]),
url=product_data.get("url"),
modified_on=modified_on,
image_count=int(product_data.get("imageCount", 0)),
ext_rarity=product_data.get("extRarity"),
ext_number=product_data.get("extNumber"),
low_price=parse_price(product_data.get("lowPrice")),
mid_price=parse_price(product_data.get("midPrice")),
high_price=parse_price(product_data.get("highPrice")),
market_price=parse_price(product_data.get("marketPrice")),
direct_low_price=parse_price(product_data.get("directLowPrice")),
sub_type_name=product_data.get("subTypeName")
)
db.add(new_product)
synced_products.append(new_product)
with transaction(db):
new_product = TCGPlayerProduct(
product_id=int(product_data["productId"]),
name=product_data["name"],
clean_name=product_data.get("cleanName"),
image_url=product_data.get("imageUrl"),
category_id=int(product_data["categoryId"]),
group_id=int(product_data["groupId"]),
url=product_data.get("url"),
modified_on=modified_on,
image_count=int(product_data.get("imageCount", 0)),
ext_rarity=product_data.get("extRarity"),
ext_number=product_data.get("extNumber"),
low_price=parse_price(product_data.get("lowPrice")),
mid_price=parse_price(product_data.get("midPrice")),
high_price=parse_price(product_data.get("highPrice")),
market_price=parse_price(product_data.get("marketPrice")),
direct_low_price=parse_price(product_data.get("directLowPrice")),
sub_type_name=product_data.get("subTypeName")
)
db.add(new_product)
synced_products.append(new_product)
db.commit()
return synced_products
async def sync_categories_to_db(self, db: Session) -> List[TCGPlayerCategory]:
@ -212,51 +212,50 @@ class TCGCSVService(BaseExternalService):
categories = response.get("results", [])
synced_categories = []
with transaction(db):
for category_data in categories:
# Convert string dates to datetime objects
modified_on = datetime.fromisoformat(category_data["modifiedOn"].replace("Z", "+00:00")) if category_data.get("modifiedOn") else None
for category_data in categories:
# Convert string dates to datetime objects
modified_on = datetime.fromisoformat(category_data["modifiedOn"].replace("Z", "+00:00")) if category_data.get("modifiedOn") else None
# Check if category already exists
existing_category = db.query(TCGPlayerCategory).filter(TCGPlayerCategory.category_id == category_data["categoryId"]).first()
if existing_category:
# Update existing category
for key, value in {
"name": category_data["name"],
"display_name": category_data.get("displayName"),
"seo_category_name": category_data.get("seoCategoryName"),
"category_description": category_data.get("categoryDescription"),
"category_page_title": category_data.get("categoryPageTitle"),
"sealed_label": category_data.get("sealedLabel"),
"non_sealed_label": category_data.get("nonSealedLabel"),
"condition_guide_url": category_data.get("conditionGuideUrl"),
"is_scannable": category_data.get("isScannable", False),
"popularity": category_data.get("popularity", 0),
"is_direct": category_data.get("isDirect", False),
"modified_on": modified_on
}.items():
setattr(existing_category, key, value)
synced_categories.append(existing_category)
else:
# Create new category
new_category = TCGPlayerCategory(
category_id=category_data["categoryId"],
name=category_data["name"],
display_name=category_data.get("displayName"),
seo_category_name=category_data.get("seoCategoryName"),
category_description=category_data.get("categoryDescription"),
category_page_title=category_data.get("categoryPageTitle"),
sealed_label=category_data.get("sealedLabel"),
non_sealed_label=category_data.get("nonSealedLabel"),
condition_guide_url=category_data.get("conditionGuideUrl"),
is_scannable=category_data.get("isScannable", False),
popularity=category_data.get("popularity", 0),
is_direct=category_data.get("isDirect", False),
modified_on=modified_on
)
db.add(new_category)
synced_categories.append(new_category)
# Check if category already exists
existing_category = db.query(TCGPlayerCategory).filter(TCGPlayerCategory.category_id == category_data["categoryId"]).first()
if existing_category:
# Update existing category
for key, value in {
"name": category_data["name"],
"display_name": category_data.get("displayName"),
"seo_category_name": category_data.get("seoCategoryName"),
"category_description": category_data.get("categoryDescription"),
"category_page_title": category_data.get("categoryPageTitle"),
"sealed_label": category_data.get("sealedLabel"),
"non_sealed_label": category_data.get("nonSealedLabel"),
"condition_guide_url": category_data.get("conditionGuideUrl"),
"is_scannable": category_data.get("isScannable", False),
"popularity": category_data.get("popularity", 0),
"is_direct": category_data.get("isDirect", False),
"modified_on": modified_on
}.items():
setattr(existing_category, key, value)
synced_categories.append(existing_category)
else:
# Create new category
new_category = TCGPlayerCategory(
category_id=category_data["categoryId"],
name=category_data["name"],
display_name=category_data.get("displayName"),
seo_category_name=category_data.get("seoCategoryName"),
category_description=category_data.get("categoryDescription"),
category_page_title=category_data.get("categoryPageTitle"),
sealed_label=category_data.get("sealedLabel"),
non_sealed_label=category_data.get("nonSealedLabel"),
condition_guide_url=category_data.get("conditionGuideUrl"),
is_scannable=category_data.get("isScannable", False),
popularity=category_data.get("popularity", 0),
is_direct=category_data.get("isDirect", False),
modified_on=modified_on
)
db.add(new_category)
synced_categories.append(new_category)
db.commit()
return synced_categories

View File

@ -67,9 +67,6 @@ class BaseTCGPlayerService(BaseExternalService):
auth_required: bool = True,
download_file: bool = False
) -> Union[Dict[str, Any], bytes]:
session = await self._get_session()
url = f"{self.base_url}{endpoint}"
# Get the authentication cookie if required
if auth_required:
cookie = self.credentials.get_cookie()
@ -83,28 +80,26 @@ class BaseTCGPlayerService(BaseExternalService):
request_headers["Cookie"] = cookie
else:
request_headers = headers or {}
try:
async with session.request(method, url, params=params, headers=request_headers, json=data) as response:
if response.status == 401:
raise RuntimeError("TCGPlayer authentication failed. Cookie may be invalid or expired.")
response.raise_for_status()
if download_file:
return await response.read()
return await response.json()
# Use the parent class's _make_request with our custom headers and binary flag
response = await super()._make_request(
method=method,
endpoint=endpoint,
params=params,
headers=request_headers,
data=data,
binary=download_file
)
if isinstance(response, dict) and response.get('status') == 401:
raise RuntimeError("TCGPlayer authentication failed. Cookie may be invalid or expired.")
return response
except aiohttp.ClientError as e:
logger.error(f"TCGPlayer API request failed: {str(e)}")
raise
except Exception as e:
logger.error(f"Unexpected error during TCGPlayer API request: {str(e)}")
raise
async def _get_session(self) -> aiohttp.ClientSession:
if self.session is None or self.session.closed:
self.session = aiohttp.ClientSession()
return self.session
async def close(self):
if self.session and not self.session.closed:
await self.session.close()
raise

View File

@ -1,7 +1,26 @@
from typing import Any, Dict, Optional, Union
import logging
from app.services.external_api.tcgplayer.base_tcgplayer_service import BaseTCGPlayerService
from app.schemas.tcgplayer import (
TCGPlayerAPIOrder,
TCGPlayerOrderCreate,
TCGPlayerOrderTransactionCreate,
TCGPlayerOrderProductCreate,
TCGPlayerOrderRefundCreate,
TCGPlayerAPIOrderSummary,
TCGPlayerAPIOrderSearchResponse
)
from app.models.tcgplayer_order import (
TCGPlayerOrder,
TCGPlayerOrderTransaction,
TCGPlayerOrderProduct,
TCGPlayerOrderRefund
)
from sqlalchemy.orm import Session
from app.db.database import transaction
import os
import csv
import io
logger = logging.getLogger(__name__)
@ -20,12 +39,20 @@ class OrderManagementService(BaseTCGPlayerService):
self.shipping_endpoint = f"/shipping/export{self.API_VERSION}"
async def get_orders(self, open_only: bool = False):
async def get_orders(self, search_range: str = "LastThreeMonths", open_only: bool = False, filter_out: list[str] = [], filter_in: list[str] = []) -> list[TCGPlayerAPIOrderSummary]:
"""
search range options:
LastWeek
LastMonth
LastThreeMonths
LastFourMonths
LastTwoYears
"""
search_from = 0
orders = []
while True:
payload = {
"searchRange": "LastThreeMonths",
"searchRange": search_range,
"filters": {
"sellerKey": self.SELLER_KEY
},
@ -37,17 +64,27 @@ class OrderManagementService(BaseTCGPlayerService):
"size": 25
}
if open_only:
payload["filters"]["orderStatus"] = ["Processing","ReadyToShip","Received","Pulling","ReadyForPickup"]
payload["filters"]["orderStatuses"] = ["Processing","ReadyToShip","Received","Pulling","ReadyForPickup"]
payload["filters"]["fulfillmentTypes"] = ["Normal"]
logger.info(f"Getting orders from {search_from} to {search_from + 25}")
response = await self._make_request("POST", self.order_search_endpoint, data=payload, headers=self._get_headers("POST", "application/json"), auth_required=True)
logger.info(f"Got {len(response.get('orders'))} orders")
if len(response.get("orders")) == 0:
break
search_from += 25
orders.extend(response.get("orders"))
if filter_out:
orders = [order for order in orders if order.get("orderNumber") not in filter_out]
if filter_in:
orders = [order for order in orders if order.get("orderNumber") in filter_in]
return orders
async def get_order_ids(self, search_range: str = "LastThreeMonths", open_only: bool = False, filter_out: list[str] = [], filter_in: list[str] = []):
orders = await self.get_orders(search_range, open_only, filter_out, filter_in)
return [order.get("orderNumber") for order in orders]
async def get_order(self, order_id: str):
response = await self._make_request("GET", f"{self.ORDER_MANAGEMENT_BASE_URL}/{order_id}{self.API_VERSION}")
response = await self._make_request("GET", f"/{order_id}{self.API_VERSION}")
return response
async def get_packing_slip(self, order_ids: list[str]):
@ -59,6 +96,7 @@ class OrderManagementService(BaseTCGPlayerService):
}
response = await self._make_request("POST", self.packing_slip_endpoint, data=payload, headers=self._get_headers("POST", "application/json"), auth_required=True, download_file=True)
return response
async def get_pull_sheet(self, order_ids: list[str]):
payload = {
"orderNumbers": order_ids,
@ -75,10 +113,283 @@ class OrderManagementService(BaseTCGPlayerService):
response = await self._make_request("POST", self.shipping_endpoint, data=payload, headers=self._get_headers("POST", "application/json"), auth_required=True, download_file=True)
return response
async def save_file(self, file_data: bytes, file_name: str) -> str:
async def save_file(self, file_data: Union[bytes, list[dict]], file_name: str) -> str:
if not os.path.exists("app/data/cache/tcgplayer/orders"):
os.makedirs("app/data/cache/tcgplayer/orders")
file_path = f"app/data/cache/tcgplayer/orders/{file_name}"
if isinstance(file_data, list):
# Convert list of dictionaries to CSV bytes
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=file_data[0].keys())
writer.writeheader()
writer.writerows(file_data)
file_data = output.getvalue().encode('utf-8')
with open(file_path, "wb") as f:
f.write(file_data)
return file_path
return file_path
async def save_order_to_db(self, order: dict, db: Session):
# Parse API response using our API schema
api_order = TCGPlayerAPIOrder.model_validate(order)
# Check if order exists
existing_order = db.query(TCGPlayerOrder).filter(TCGPlayerOrder.order_number == api_order.orderNumber).first()
# Create database models
db_order = TCGPlayerOrderCreate(
order_number=api_order.orderNumber,
order_created_at=api_order.createdAt,
status=api_order.status,
channel=api_order.orderChannel,
fulfillment=api_order.orderFulfillment,
seller_name=api_order.sellerName,
buyer_name=api_order.buyerName,
payment_type=api_order.paymentType,
pickup_status=api_order.pickupStatus,
shipping_type=api_order.shippingType,
estimated_delivery_date=api_order.estimatedDeliveryDate,
recipient_name=api_order.shippingAddress.recipientName,
address_line_1=api_order.shippingAddress.addressOne,
address_line_2=api_order.shippingAddress.addressTwo,
city=api_order.shippingAddress.city,
state=api_order.shippingAddress.territory,
zip_code=api_order.shippingAddress.postalCode,
country=api_order.shippingAddress.country,
tracking_numbers=[t.trackingNumber for t in api_order.trackingNumbers]
)
# Create transaction
db_transaction = TCGPlayerOrderTransactionCreate(
order_number=api_order.orderNumber,
product_amount=api_order.transaction.productAmount,
shipping_amount=api_order.transaction.shippingAmount,
gross_amount=api_order.transaction.grossAmount,
fee_amount=api_order.transaction.feeAmount,
net_amount=api_order.transaction.netAmount,
direct_fee_amount=api_order.transaction.directFeeAmount,
taxes=[{"code": t.code, "amount": t.amount} for t in api_order.transaction.taxes]
)
# Create products
db_products = [
TCGPlayerOrderProductCreate(
order_number=api_order.orderNumber,
product_name=p.name,
unit_price=p.unitPrice,
extended_price=p.extendedPrice,
quantity=p.quantity,
url=p.url,
product_id=p.productId,
sku_id=p.skuId
)
for p in api_order.products
]
# Create refunds if they exist
db_refunds = []
if api_order.refunds:
db_refunds = [
TCGPlayerOrderRefundCreate(
order_number=api_order.orderNumber,
refund_created_at=r.createdAt,
type=r.type,
amount=r.amount,
description=r.description,
origin=r.origin,
shipping_amount=r.shippingAmount,
products=r.products
)
for r in api_order.refunds
]
with transaction(db):
try:
if existing_order:
# Check if order needs to be updated
if not self._order_needs_update(existing_order, api_order, db):
logger.info(f"Order {api_order.orderNumber} is up to date, skipping update")
return existing_order
logger.info(f"Updating existing order {api_order.orderNumber}")
# Update existing order
for key, value in db_order.model_dump().items():
setattr(existing_order, key, value)
db_order_model = existing_order
# Update transaction
existing_transaction = db.query(TCGPlayerOrderTransaction).filter(
TCGPlayerOrderTransaction.order_number == api_order.orderNumber
).first()
if existing_transaction:
for key, value in db_transaction.model_dump().items():
setattr(existing_transaction, key, value)
else:
db_transaction_model = TCGPlayerOrderTransaction(**db_transaction.model_dump())
db.add(db_transaction_model)
# Delete existing products and refunds
db.query(TCGPlayerOrderProduct).filter(
TCGPlayerOrderProduct.order_number == api_order.orderNumber
).delete()
db.query(TCGPlayerOrderRefund).filter(
TCGPlayerOrderRefund.order_number == api_order.orderNumber
).delete()
else:
logger.info(f"Creating new order {api_order.orderNumber}")
# Create new order
db_order_model = TCGPlayerOrder(**db_order.model_dump())
db.add(db_order_model)
db.flush() # Get the order ID
# Create transaction
db_transaction_model = TCGPlayerOrderTransaction(**db_transaction.model_dump())
db.add(db_transaction_model)
# Create products
for product in db_products:
db_product_model = TCGPlayerOrderProduct(**product.model_dump())
db.add(db_product_model)
# Create refunds
for refund in db_refunds:
db_refund_model = TCGPlayerOrderRefund(**refund.model_dump())
db.add(db_refund_model)
db.commit()
return db_order_model
except Exception as e:
logger.error(f"Error saving/updating order {api_order.orderNumber} to database: {str(e)}")
raise
def _order_needs_update(self, existing_order: TCGPlayerOrder, new_order: TCGPlayerAPIOrder, db: Session) -> bool:
"""
Compare existing order data with new order data to determine if an update is needed.
Returns True if the order needs to be updated, False otherwise.
"""
# Compare basic order fields
order_fields_to_compare = [
('status', 'status'),
('channel', 'orderChannel'),
('fulfillment', 'orderFulfillment'),
('seller_name', 'sellerName'),
('buyer_name', 'buyerName'),
('payment_type', 'paymentType'),
('pickup_status', 'pickupStatus'),
('shipping_type', 'shippingType'),
('recipient_name', 'shippingAddress.recipientName'),
('address_line_1', 'shippingAddress.addressOne'),
('address_line_2', 'shippingAddress.addressTwo'),
('city', 'shippingAddress.city'),
('state', 'shippingAddress.territory'),
('zip_code', 'shippingAddress.postalCode'),
('country', 'shippingAddress.country'),
('tracking_numbers', 'trackingNumbers')
]
for db_field, api_field in order_fields_to_compare:
existing_value = getattr(existing_order, db_field)
# Handle nested fields
if '.' in api_field:
parts = api_field.split('.')
new_value = new_order
for part in parts:
new_value = getattr(new_value, part)
else:
new_value = getattr(new_order, api_field)
# Handle special cases for tracking numbers
if db_field == 'tracking_numbers':
if set(existing_value or []) != set([t.trackingNumber for t in new_value or []]):
return True
continue
if existing_value != new_value:
return True
# Compare transaction data
existing_transaction = db.query(TCGPlayerOrderTransaction).filter(
TCGPlayerOrderTransaction.order_number == existing_order.order_number
).first()
if existing_transaction:
transaction_fields_to_compare = [
('product_amount', 'productAmount'),
('shipping_amount', 'shippingAmount'),
('gross_amount', 'grossAmount'),
('fee_amount', 'feeAmount'),
('net_amount', 'netAmount'),
('direct_fee_amount', 'directFeeAmount')
]
for db_field, api_field in transaction_fields_to_compare:
if getattr(existing_transaction, db_field) != getattr(new_order.transaction, api_field):
return True
# Compare taxes
existing_taxes = sorted(existing_transaction.taxes, key=lambda x: x['code'])
new_taxes = sorted(new_order.transaction.taxes, key=lambda x: x.code)
if len(existing_taxes) != len(new_taxes):
return True
for existing_tax, new_tax in zip(existing_taxes, new_taxes):
if existing_tax['code'] != new_tax.code or existing_tax['amount'] != new_tax.amount:
return True
# Compare products
existing_products = db.query(TCGPlayerOrderProduct).filter(
TCGPlayerOrderProduct.order_number == existing_order.order_number
).all()
if len(existing_products) != len(new_order.products):
return True
# Sort products by product_id for comparison
existing_products_sorted = sorted(existing_products, key=lambda x: x.product_id)
new_products_sorted = sorted(new_order.products, key=lambda x: x.productId)
for existing_product, new_product in zip(existing_products_sorted, new_products_sorted):
product_fields_to_compare = [
('product_name', 'name'),
('unit_price', 'unitPrice'),
('extended_price', 'extendedPrice'),
('quantity', 'quantity'),
('url', 'url'),
('product_id', 'productId'),
('sku_id', 'skuId')
]
for db_field, api_field in product_fields_to_compare:
if getattr(existing_product, db_field) != getattr(new_product, api_field):
return True
# Compare refunds
existing_refunds = db.query(TCGPlayerOrderRefund).filter(
TCGPlayerOrderRefund.order_number == existing_order.order_number
).all()
if len(existing_refunds) != len(new_order.refunds or []):
return True
# Sort refunds by created_at for comparison
existing_refunds_sorted = sorted(existing_refunds, key=lambda x: x.refund_created_at)
new_refunds_sorted = sorted(new_order.refunds or [], key=lambda x: x.createdAt)
for existing_refund, new_refund in zip(existing_refunds_sorted, new_refunds_sorted):
refund_fields_to_compare = [
('type', 'type'),
('amount', 'amount'),
('description', 'description'),
('origin', 'origin'),
('shipping_amount', 'shippingAmount'),
('products', 'products')
]
for db_field, api_field in refund_fields_to_compare:
if getattr(existing_refund, db_field) != getattr(new_refund, api_field):
return True
return False

View File

@ -27,6 +27,13 @@ class LabelPrinterService:
self._session = None
self._lock = asyncio.Lock()
async def cleanup(self):
"""Clean up resources, particularly the aiohttp session."""
if self._session:
await self._session.close()
self._session = None
logger.info("Label printer service session closed")
@asynccontextmanager
async def _get_session(self):
"""Context manager for aiohttp session."""
@ -64,8 +71,16 @@ class LabelPrinterService:
elif response.status == 404:
logger.error(f"Printer status endpoint not found at {self.status_url}")
return False
elif response.status == 500:
data = await response.json()
error_msg = data.get('message', 'Unknown printer error')
logger.error(f"Printer error: {error_msg}")
raise Exception(f"Printer error: {error_msg}")
except aiohttp.ClientError as e:
logger.warning(f"Error checking printer status: {e}")
if "Cannot connect to host" in str(e):
logger.error("Printer reciever is not available")
raise Exception("Printer reciever is not available")
except Exception as e:
logger.error(f"Unexpected error in _wait_for_printer_ready: {e}")
return False
@ -109,6 +124,11 @@ class LabelPrinterService:
elif response.status == 429:
logger.error("Printer is busy")
return False
elif response.status == 500:
data = await response.json()
error_msg = data.get('message', 'Unknown printer error')
logger.error(f"Printer error: {error_msg}")
raise Exception(f"Printer error: {error_msg}")
else:
data = await response.json()
logger.error(f"Print request failed with status {response.status}: {data.get('message')}")
@ -121,13 +141,13 @@ class LabelPrinterService:
logger.error(f"Unexpected error in _send_print_request: {e}")
return False
async def print_file(self, file_path: Union[str, Path], label_size: Literal["dk1201", "dk1241"], label_type: Optional[Literal["address_label", "packing_slip"]] = None) -> bool:
async def print_file(self, file_path: Union[str, Path], label_size: Literal["dk1201", "dk1241"], label_type: Optional[Literal["address_label", "packing_slip", "set_label"]] = None) -> bool:
"""Print a PDF or PNG file to the label printer.
Args:
file_path: Path to the PDF or PNG file
label_size: Size of label to use ("dk1201" or "dk1241")
label_type: Type of label to use ("address_label" or "packing_slip")
label_type: Type of label to use ("address_label" or "packing_slip" or "set_label")
Returns:
bool: True if print was successful, False otherwise
@ -177,14 +197,16 @@ class LabelPrinterService:
resized_image = resized_image.resize((1660, 1164), Image.Resampling.LANCZOS)
else:
resized_image = resized_image.resize((1164, 1660), Image.Resampling.LANCZOS)
elif original_label_size == "dk1201" and label_type == "address_label":
elif original_label_size == "dk1201":
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":
if label_type == "address_label" or label_type == "set_label":
rotate = "90"
cut = False
else:
rotate = "0"
cut = True
# Convert to label format
qlr = BrotherQLRaster("QL-1100")
@ -205,7 +227,7 @@ class LabelPrinterService:
dpi_600=False,
#hq=True,
hq=False,
cut=True
cut=cut
)
# Cache the converted binary data

View File

@ -1,58 +0,0 @@
from sqlalchemy.orm import Session
from app.services.base_service import BaseService
from app.models.order import Order, OrderCard
from app.models.card import Card
class OrderService(BaseService):
def __init__(self):
super().__init__(Order)
def create_order_with_cards(self, db: Session, order_data: dict, card_ids: list[int]) -> Order:
"""
Create a new order with associated cards.
Args:
db: Database session
order_data: Dictionary containing order details
card_ids: List of card IDs to associate with the order
Returns:
The created Order object
"""
# Create the order
order = Order(**order_data)
db.add(order)
db.flush() # Get the order ID
# Associate cards with the order
for card_id in card_ids:
card = db.query(Card).filter(Card.id == card_id).first()
if not card:
raise ValueError(f"Card with ID {card_id} not found")
order_card = OrderCard(order_id=order.id, card_id=card_id)
db.add(order_card)
db.commit()
db.refresh(order)
return order
def get_orders_with_cards(self, db: Session, skip: int = 0, limit: int = 10) -> list[Order]:
"""
Get orders with their associated cards.
Args:
db: Database session
skip: Number of records to skip
limit: Maximum number of records to return
Returns:
List of Order objects with their associated cards
"""
orders = db.query(Order).offset(skip).limit(limit).all()
# Eager load the cards for each order
for order in orders:
order.cards = db.query(Card).join(OrderCard).filter(OrderCard.order_id == order.id).all()
return orders

View File

@ -5,6 +5,7 @@ from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from weasyprint import HTML
import logging
import asyncio
logger = logging.getLogger(__name__)
@ -16,7 +17,7 @@ class PullSheetService:
self.output_dir = Path("app/data/cache/tcgplayer/pull_sheets")
self.output_dir.mkdir(parents=True, exist_ok=True)
def generate_pull_sheet_pdf(self, csv_path: str) -> str:
async def generate_pull_sheet_pdf(self, csv_path: str) -> str:
"""Generate a PDF pull sheet from a CSV file.
Args:
@ -27,7 +28,7 @@ class PullSheetService:
"""
try:
# Read and process CSV data
items = self._read_and_process_csv(csv_path)
items = await self._read_and_process_csv(csv_path)
# Prepare template data
template_data = {
@ -38,9 +39,12 @@ class PullSheetService:
# Render HTML
html_content = self.template.render(**template_data)
# Generate PDF
# Generate PDF in a separate thread to avoid blocking
pdf_path = self.output_dir / f"pull_sheet_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
HTML(string=html_content).write_pdf(str(pdf_path))
await asyncio.get_event_loop().run_in_executor(
None,
lambda: HTML(string=html_content).write_pdf(str(pdf_path))
)
return str(pdf_path)
@ -48,7 +52,7 @@ class PullSheetService:
logger.error(f"Error generating pull sheet PDF: {str(e)}")
raise
def _read_and_process_csv(self, csv_path: str) -> List[Dict]:
async def _read_and_process_csv(self, csv_path: str) -> List[Dict]:
"""Read and process CSV data using pandas.
Args:
@ -57,8 +61,11 @@ class PullSheetService:
Returns:
List of processed items
"""
# Read CSV into pandas DataFrame
df = pd.read_csv(csv_path)
# Read CSV into pandas DataFrame in a separate thread to avoid blocking
df = await asyncio.get_event_loop().run_in_executor(
None,
lambda: pd.read_csv(csv_path)
)
# Filter out the "Orders Contained in Pull Sheet" row
df = df[df['Product Line'] != 'Orders Contained in Pull Sheet:']
@ -67,7 +74,7 @@ class PullSheetService:
df['Set Release Date'] = pd.to_datetime(df['Set Release Date'], format='%m/%d/%Y %H:%M:%S')
# Sort by Set Release Date (descending) and then Product Name (ascending)
df = df.sort_values(['Set Release Date', 'Product Name'], ascending=[False, True])
df = df.sort_values(['Set Release Date', 'Set', 'Product Name'], ascending=[False, True, True])
# Convert to list of dictionaries
items = []
@ -77,7 +84,8 @@ class PullSheetService:
'condition': row['Condition'],
'quantity': str(int(row['Quantity'])), # Convert to string for template
'set': row['Set'],
'rarity': row['Rarity']
'rarity': row['Rarity'],
'card_number': str(int(row['Number'])) if 'Number' in row else ''
})
return items

View File

@ -2,7 +2,7 @@ from typing import Callable, Dict, Any
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
import logging
from app.services.service_registry import ServiceRegistry
from apscheduler.schedulers.base import SchedulerNotRunningError
logger = logging.getLogger(__name__)
@ -10,7 +10,7 @@ class BaseScheduler:
def __init__(self):
self.scheduler = AsyncIOScheduler()
self.jobs: Dict[str, Any] = {}
ServiceRegistry.register(self.__class__.__name__, self)
self._is_running = False
async def schedule_task(
self,
@ -46,16 +46,20 @@ class BaseScheduler:
def start(self) -> None:
"""Start the scheduler"""
self.scheduler.start()
logger.info("Scheduler started")
if not self._is_running:
self.scheduler.start()
self._is_running = True
logger.info("Scheduler started")
async def shutdown(self) -> None:
"""Shutdown the scheduler"""
try:
self.scheduler.shutdown()
logger.info("Scheduler stopped")
except AttributeError as e:
if "'NoneType' object has no attribute 'call_soon_threadsafe'" in str(e):
logger.warning("Event loop already closed, skipping scheduler shutdown")
else:
if self._is_running:
try:
self.scheduler.shutdown()
self._is_running = False
logger.info("Scheduler stopped")
except SchedulerNotRunningError:
logger.warning("Scheduler was already stopped")
except Exception as e:
logger.error(f"Error shutting down scheduler: {str(e)}")
raise

View File

@ -1,7 +1,4 @@
from sqlalchemy.orm import Session
from app.db.database import SessionLocal, transaction
from app.services.external_api.tcgplayer.tcgplayer_inventory_service import TCGPlayerInventoryService
from app.services.file_processing_service import FileProcessingService
from app.db.database import get_db, transaction
from app.services.scheduler.base_scheduler import BaseScheduler
import logging
@ -9,9 +6,16 @@ logger = logging.getLogger(__name__)
class SchedulerService:
def __init__(self):
self.tcgplayer_service = TCGPlayerInventoryService()
self.file_processor = FileProcessingService()
self.scheduler = BaseScheduler()
# Service manager will be set during initialization
self._service_manager = None
@property
def service_manager(self):
if self._service_manager is None:
from app.services.service_manager import ServiceManager
self._service_manager = ServiceManager()
return self._service_manager
async def process_tcgplayer_export(self, export_type: str = "live", use_cache: bool = False):
"""
@ -20,16 +24,20 @@ class SchedulerService:
Args:
export_type: Type of export to process (staged, live, or pricing)
"""
db = SessionLocal()
db = get_db()
try:
logger.info(f"Starting scheduled TCGPlayer export processing for {export_type}")
# Get services
tcgplayer_service = self.service_manager.get_service('tcgplayer_inventory')
file_processor = self.service_manager.get_service('file_processing')
# Download the file
file_bytes = await self.tcgplayer_service.get_tcgplayer_export(export_type)
file_bytes = await tcgplayer_service.get_tcgplayer_export(export_type)
# Process the file and load into database
with transaction(db):
stats = await self.file_processor.process_tcgplayer_export(db, export_type=export_type, file_bytes=file_bytes, use_cache=use_cache)
stats = await file_processor.process_tcgplayer_export(db, export_type=export_type, file_bytes=file_bytes, use_cache=use_cache)
logger.info(f"Completed TCGPlayer export processing: {stats}")
return stats
@ -37,9 +45,53 @@ class SchedulerService:
except Exception as e:
logger.error(f"Error processing TCGPlayer export: {str(e)}")
raise
finally:
db.close()
async def update_open_orders_hourly(self):
"""
Hourly update of orders from TCGPlayer API to database
"""
db = get_db()
try:
logger.info("Starting hourly order update")
# Get order management service
order_management = self.service_manager.get_service('order_management')
# get all open orders from last 7 days
orders = await order_management.get_order_ids(open_only=True, search_range="LastWeek")
for order_id in orders:
order = await order_management.get_order(order_id)
with transaction(db):
await order_management.save_order_to_db(order, db)
logger.info("Completed hourly order update")
except Exception as e:
logger.error(f"Error updating open orders: {str(e)}")
raise
async def update_all_orders_daily(self):
"""
Daily update of all orders from TCGPlayer API to database
"""
db = get_db()
try:
logger.info("Starting daily order update")
# Get order management service
order_management = self.service_manager.get_service('order_management')
# get all order ids from last 3 months
orders = await order_management.get_order_ids(open_only=False, search_range="LastThreeMonths")
for order_id in orders:
order = await order_management.get_order(order_id)
with transaction(db):
await order_management.save_order_to_db(order, db)
logger.info("Completed daily order update")
except Exception as e:
logger.error(f"Error updating all orders: {str(e)}")
raise
async def start_scheduled_tasks(self):
"""Start all scheduled tasks"""
# Schedule TCGPlayer export processing to run daily at 2 AM
@ -49,6 +101,27 @@ class SchedulerService:
interval_seconds=24 * 60 * 60, # 24 hours
export_type="live"
)
# Schedule open orders update to run hourly at 00 minutes
await self.scheduler.schedule_task(
task_name="update_open_orders_hourly",
func=self.update_open_orders_hourly,
interval_seconds=60 * 60, # 1 hour
)
# Schedule all orders update to run daily at 1 AM
await self.scheduler.schedule_task(
task_name="update_all_orders_daily",
func=self.update_all_orders_daily,
interval_seconds=24 * 60 * 60, # 24 hours
)
self.scheduler.start()
logger.info("All scheduled tasks started")
logger.info("All scheduled tasks started")
async def close(self):
"""Close all services used by the scheduler"""
try:
await self.scheduler.shutdown()
logger.info("Scheduler services closed")
except Exception as e:
logger.error(f"Error closing scheduler services: {str(e)}")
raise

View File

@ -0,0 +1,84 @@
from typing import Dict, Any, Type
import logging
import importlib
logger = logging.getLogger(__name__)
class ServiceManager:
_instance = None
_initialized = False
def __new__(cls):
if cls._instance is None:
cls._instance = super(ServiceManager, cls).__new__(cls)
return cls._instance
def __init__(self):
if not self._initialized:
self.services: Dict[str, Any] = {}
self._service_classes = {
'order_management': 'app.services.external_api.tcgplayer.order_management_service.OrderManagementService',
'tcgplayer_inventory': 'app.services.external_api.tcgplayer.tcgplayer_inventory_service.TCGPlayerInventoryService',
'label_printer': 'app.services.label_printer_service.LabelPrinterService',
'regular_printer': 'app.services.regular_printer_service.RegularPrinterService',
'address_label': 'app.services.address_label_service.AddressLabelService',
'pull_sheet': 'app.services.pull_sheet_service.PullSheetService',
'set_label': 'app.services.set_label_service.SetLabelService',
'data_initialization': 'app.services.data_initialization.DataInitializationService',
'scheduler': 'app.services.scheduler.scheduler_service.SchedulerService'
}
self._service_configs = {
'label_printer': {'printer_api_url': "http://192.168.1.110:8000"},
'regular_printer': {'printer_name': "MFCL2750DW-3"}
}
self._initialized = True
def _import_service(self, module_path: str) -> Type:
"""Dynamically import a service class"""
module_name, class_name = module_path.rsplit('.', 1)
module = importlib.import_module(module_name)
return getattr(module, class_name)
def register_service(self, service: Any) -> None:
"""Register a service with the service manager"""
service_name = service.__class__.__name__
self.services[service_name] = service
logger.info(f"Service {service_name} registered")
async def initialize_services(self):
"""Initialize all services"""
try:
# Initialize services
for name, class_path in self._service_classes.items():
service_class = self._import_service(class_path)
config = self._service_configs.get(name, {})
self.services[name] = service_class(**config)
self.register_service(self.services[name])
logger.info("All services initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize services: {str(e)}")
raise
async def cleanup_services(self):
"""Clean up all services"""
try:
# Clean up services in reverse order of initialization
for name, service in reversed(self.services.items()):
if hasattr(service, 'close'):
await service.close()
elif hasattr(service, 'cleanup'):
await service.cleanup()
logger.info(f"Service {name} cleaned up")
self.services.clear()
logger.info("All services cleaned up successfully")
except Exception as e:
logger.error(f"Failed to clean up services: {str(e)}")
raise
def get_service(self, name: str) -> Any:
"""Get a service by name"""
if name not in self.services:
raise ValueError(f"Service {name} not found")
return self.services[name]

View File

@ -1,18 +0,0 @@
from typing import Dict, Any
class ServiceRegistry:
_services: Dict[str, Any] = {}
@classmethod
def register(cls, name: str, service: Any) -> None:
cls._services[name] = service
@classmethod
def get(cls, name: str) -> Any:
if name not in cls._services:
raise ValueError(f"Service {name} not found in registry")
return cls._services[name]
@classmethod
def clear(cls) -> None:
cls._services.clear()

View File

@ -0,0 +1,262 @@
import argparse
import asyncio
import base64
import logging
import os
from datetime import datetime
from pathlib import Path
from sqlalchemy.orm import Session
import aiohttp
import jinja2
from weasyprint import HTML
from app.services.base_service import BaseService
from app.models.tcgplayer_group import TCGPlayerGroup
log = logging.getLogger(__name__)
ENV = jinja2.Environment(
loader=jinja2.FileSystemLoader("app/data/assets/templates"))
# Set types we are interested in
SET_TYPES = (
"core",
"expansion",
"starter", # Portal, P3k, welcome decks
"masters",
"commander",
"planechase",
"draft_innovation", # Battlebond, Conspiracy
"duel_deck", # Duel Deck Elves,
"premium_deck", # Premium Deck Series: Slivers, Premium Deck Series: Graveborn
"from_the_vault", # Make sure to adjust the MINIMUM_SET_SIZE if you want these
"archenemy",
"box",
"funny", # Unglued, Unhinged, Ponies: TG, etc.
# "memorabilia", # Commander's Arsenal, Celebration Cards, World Champ Decks
# "spellbook",
# These are relatively large groups of sets
# You almost certainly don't want these
# "token",
# "promo",
)
# Only include sets at least this size
# For reference, the smallest proper expansion is Arabian Nights with 78 cards
MINIMUM_SET_SIZE = 50
# Set codes you might want to ignore
IGNORED_SETS = (
"cmb1", # Mystery Booster Playtest Cards
"amh1", # Modern Horizon Art Series
"cmb2", # Mystery Booster Playtest Cards Part Deux
)
# Used to rename very long set names
RENAME_SETS = {
"Adventures in the Forgotten Realms": "Forgotten Realms",
"Adventures in the Forgotten Realms Minigames": "Forgotten Realms Minigames",
"Angels: They're Just Like Us but Cooler and with Wings": "Angels: Just Like Us",
"Archenemy: Nicol Bolas Schemes": "Archenemy: Bolas Schemes",
"Chronicles Foreign Black Border": "Chronicles FBB",
"Commander Anthology Volume II": "Commander Anthology II",
"Commander Legends: Battle for Baldur's Gate": "CMDR Legends: Baldur's Gate",
"Dominaria United Commander": "Dominaria United [C]",
"Duel Decks: Elves vs. Goblins": "DD: Elves vs. Goblins",
"Duel Decks: Jace vs. Chandra": "DD: Jace vs. Chandra",
"Duel Decks: Divine vs. Demonic": "DD: Divine vs. Demonic",
"Duel Decks: Garruk vs. Liliana": "DD: Garruk vs. Liliana",
"Duel Decks: Phyrexia vs. the Coalition": "DD: Phyrexia vs. Coalition",
"Duel Decks: Elspeth vs. Tezzeret": "DD: Elspeth vs. Tezzeret",
"Duel Decks: Knights vs. Dragons": "DD: Knights vs. Dragons",
"Duel Decks: Ajani vs. Nicol Bolas": "DD: Ajani vs. Nicol Bolas",
"Duel Decks: Heroes vs. Monsters": "DD: Heroes vs. Monsters",
"Duel Decks: Speed vs. Cunning": "DD: Speed vs. Cunning",
"Duel Decks Anthology: Elves vs. Goblins": "DDA: Elves vs. Goblins",
"Duel Decks Anthology: Jace vs. Chandra": "DDA: Jace vs. Chandra",
"Duel Decks Anthology: Divine vs. Demonic": "DDA: Divine vs. Demonic",
"Duel Decks Anthology: Garruk vs. Liliana": "DDA: Garruk vs. Liliana",
"Duel Decks: Elspeth vs. Kiora": "DD: Elspeth vs. Kiora",
"Duel Decks: Zendikar vs. Eldrazi": "DD: Zendikar vs. Eldrazi",
"Duel Decks: Blessed vs. Cursed": "DD: Blessed vs. Cursed",
"Duel Decks: Nissa vs. Ob Nixilis": "DD: Nissa vs. Ob Nixilis",
"Duel Decks: Merfolk vs. Goblins": "DD: Merfolk vs. Goblins",
"Duel Decks: Elves vs. Inventors": "DD: Elves vs. Inventors",
"Duel Decks: Mirrodin Pure vs. New Phyrexia": "DD: Mirrodin vs.New Phyrexia",
"Duel Decks: Izzet vs. Golgari": "Duel Decks: Izzet vs. Golgari",
"Fourth Edition Foreign Black Border": "Fourth Edition FBB",
"Global Series Jiang Yanggu & Mu Yanling": "Jiang Yanggu & Mu Yanling",
"Innistrad: Crimson Vow Minigames": "Crimson Vow Minigames",
"Introductory Two-Player Set": "Intro Two-Player Set",
"March of the Machine: The Aftermath": "MotM: The Aftermath",
"March of the Machine Commander": "March of the Machine [C]",
"Murders at Karlov Manor Commander": "Murders at Karlov Manor [C]",
"Mystery Booster Playtest Cards": "Mystery Booster Playtest",
"Mystery Booster Playtest Cards 2019": "MB Playtest Cards 2019",
"Mystery Booster Playtest Cards 2021": "MB Playtest Cards 2021",
"Mystery Booster Retail Edition Foils": "Mystery Booster Retail Foils",
"Outlaws of Thunder Junction Commander": "Outlaws of Thunder Junction [C]",
"Phyrexia: All Will Be One Commander": "Phyrexia: All Will Be One [C]",
"Planechase Anthology Planes": "Planechase Anth. Planes",
"Premium Deck Series: Slivers": "Premium Deck Slivers",
"Premium Deck Series: Graveborn": "Premium Deck Graveborn",
"Premium Deck Series: Fire and Lightning": "PD: Fire & Lightning",
"Shadows over Innistrad Remastered": "SOI Remastered",
"Strixhaven: School of Mages Minigames": "Strixhaven Minigames",
"Tales of Middle-earth Commander": "Tales of Middle-earth [C]",
"The Brothers' War Retro Artifacts": "Brothers' War Retro",
"The Brothers' War Commander": "Brothers' War Commander",
"The Lord of the Rings: Tales of Middle-earth": "LOTR: Tales of Middle-earth",
"The Lost Caverns of Ixalan Commander": "The Lost Caverns of Ixalan [C]",
"Warhammer 40,000 Commander": "Warhammer 40K [C]",
"Wilds of Eldraine Commander": "Wilds of Eldraine [C]",
"World Championship Decks 1997": "World Championship 1997",
"World Championship Decks 1998": "World Championship 1998",
"World Championship Decks 1999": "World Championship 1999",
"World Championship Decks 2000": "World Championship 2000",
"World Championship Decks 2001": "World Championship 2001",
"World Championship Decks 2002": "World Championship 2002",
"World Championship Decks 2003": "World Championship 2003",
"World Championship Decks 2004": "World Championship 2004",
}
class SetLabelService(BaseService):
DEFAULT_OUTPUT_DIR = "app/data/cache/set_labels"
os.makedirs(DEFAULT_OUTPUT_DIR, exist_ok=True)
def __init__(self, output_dir=DEFAULT_OUTPUT_DIR):
super().__init__(None) # BaseService doesn't need a model for this service
self.set_codes = []
self.ignored_sets = IGNORED_SETS
self.set_types = SET_TYPES
self.minimum_set_size = MINIMUM_SET_SIZE
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
async def get_set_data(self, session):
log.info("Getting set data and icons from Scryfall")
async with session.get("https://api.scryfall.com/sets") as resp:
resp.raise_for_status()
data = (await resp.json())["data"]
set_data = []
for exp in data:
if exp["code"] in self.ignored_sets:
continue
elif exp["card_count"] < self.minimum_set_size:
continue
elif self.set_types and exp["set_type"] not in self.set_types:
continue
elif self.set_codes and exp["code"].lower() not in self.set_codes:
continue
else:
set_data.append(exp)
if self.set_codes:
known_sets = set([exp["code"] for exp in data])
specified_sets = set([code.lower() for code in self.set_codes])
unknown_sets = specified_sets.difference(known_sets)
for set_code in unknown_sets:
log.warning("Unknown set '%s'", set_code)
set_data.reverse()
return set_data
async def get_set_icon(self, session, icon_url):
try:
async with session.get(icon_url) as resp:
if resp.status == 200:
icon_data = await resp.read()
return base64.b64encode(icon_data).decode('utf-8')
except Exception as e:
log.warning(f"Failed to fetch icon from {icon_url}: {e}")
return None
async def generate_label(self, session, set_data):
output_file = self.output_dir / f"{set_data['code']}.pdf"
# Check if file already exists
if output_file.exists():
log.info(f"Label already exists for {set_data['name']} ({set_data['code']})")
return output_file
name = RENAME_SETS.get(set_data["name"], set_data["name"])
icon_b64 = await self.get_set_icon(session, set_data["icon_svg_uri"])
template = ENV.get_template("set_label.html")
html_content = template.render(
name=name,
code=set_data["code"],
date=datetime.strptime(set_data["released_at"], "%Y-%m-%d").date(),
icon_b64=icon_b64,
)
HTML(string=html_content).write_pdf(output_file)
log.info(f"Generated label for {name} ({set_data['code']})")
return output_file
async def generate_labels(self, sets=None):
if sets:
self.ignored_sets = ()
self.minimum_set_size = 0
self.set_types = ()
self.set_codes = [exp.lower() for exp in sets]
async with aiohttp.ClientSession() as session:
set_data = await self.get_set_data(session)
tasks = [self.generate_label(session, exp) for exp in set_data]
return await asyncio.gather(*tasks)
async def get_available_sets(self, db: Session):
"""
Get a list of available MTG sets that can be used for label generation.
Returns:
List of set codes and their names
"""
try:
# Get all sets from the database
sets = db.query(TCGPlayerGroup).filter(
TCGPlayerGroup.category_id == 1,
TCGPlayerGroup.abbreviation.isnot(None),
TCGPlayerGroup.abbreviation != ""
).all()
if not sets:
log.warning("No sets found in database")
return []
return [{"code": set.abbreviation, "name": set.name} for set in sets]
except Exception as e:
log.error(f"Error getting available sets: {str(e)}")
raise
def main():
log_format = '[%(levelname)s] %(message)s'
logging.basicConfig(format=log_format, level=logging.INFO)
parser = argparse.ArgumentParser(description="Generate MTG labels")
parser.add_argument(
"--output-dir",
default=SetLabelService.DEFAULT_OUTPUT_DIR,
help="Output labels to this directory",
)
parser.add_argument(
"sets",
nargs="*",
help=(
"Only output sets with the specified set code (eg. MH1, NEO). "
"This can be used multiple times."
),
metavar="SET",
)
args = parser.parse_args()
service = SetLabelService(args.output_dir)
asyncio.run(service.generate_labels(args.sets))
if __name__ == "__main__":
main()

View File

@ -139,12 +139,18 @@ class LabelPrinterReceiver:
# Use the send helper which handles the complete print lifecycle
logger.info("Sending print data to printer...")
result = send(
instructions=print_data,
printer_identifier=self.printer_identifier,
backend_identifier='pyusb',
blocking=True
)
try:
result = send(
instructions=print_data,
printer_identifier=self.printer_identifier,
backend_identifier='pyusb',
blocking=True
)
except Exception as e:
if "Device not found" in str(e):
logger.error("Printer device not found")
return {"message": "Printer device not found"}, 500
raise
logger.debug(f"Print result: {result}")

1
tests/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file makes the tests directory a Python package

View File

@ -0,0 +1,95 @@
from fastapi.testclient import TestClient
from unittest.mock import patch, MagicMock
from app.main import app
from app.services.set_label_service import SetLabelService
from sqlalchemy.orm import Session
from app.db.database import get_db
import asyncio
client = TestClient(app)
def test_generate_set_labels_success():
# Mock the SetLabelService
with patch('app.routes.set_label_routes.set_label_service') as mock_service:
# Setup mock
mock_service.generate_labels.return_value = asyncio.Future()
mock_service.generate_labels.return_value.set_result([
"app/data/cache/set_labels/MH1.pdf",
"app/data/cache/set_labels/NEO.pdf"
])
# Make request
response = client.post("/api/set-labels/generate", json={"sets": ["MH1", "NEO"]})
# Assert response
assert response.status_code == 200
assert response.json() == {
"message": "Labels generated successfully",
"files": [
"app/data/cache/set_labels/MH1.pdf",
"app/data/cache/set_labels/NEO.pdf"
]
}
# Verify service was called correctly
mock_service.generate_labels.assert_called_once_with(["MH1", "NEO"])
def test_generate_set_labels_error():
# Mock the SetLabelService to raise an exception
with patch('app.routes.set_label_routes.set_label_service') as mock_service:
mock_service.generate_labels.side_effect = Exception("Test error")
# Make request
response = client.post("/api/set-labels/generate", json={"sets": ["MH1", "NEO"]})
# Assert response
assert response.status_code == 500
assert response.json() == {"detail": "Test error"}
def test_get_available_sets_success():
# Mock the database session and SetLabelService
with patch('app.routes.set_label_routes.set_label_service') as mock_service, \
patch('app.db.database.get_db') as mock_get_db:
# Setup mock database session
mock_db = MagicMock(spec=Session)
mock_get_db.return_value = mock_db
# Setup mock service
mock_sets = [
{"code": "MH1", "name": "Modern Horizons"},
{"code": "NEO", "name": "Kamigawa: Neon Dynasty"}
]
mock_service.get_available_sets.return_value = asyncio.Future()
mock_service.get_available_sets.return_value.set_result(mock_sets)
# Override the dependency injection
app.dependency_overrides[get_db] = lambda: mock_db
try:
# Make request
response = client.get("/api/set-labels/available-sets")
# Assert response
assert response.status_code == 200
assert response.json() == {
"message": "Available sets retrieved successfully",
"sets": mock_sets
}
# Verify service was called correctly
mock_service.get_available_sets.assert_called_once_with(mock_db)
finally:
# Clean up dependency override
app.dependency_overrides.clear()
def test_get_available_sets_error():
# Mock the SetLabelService to raise an exception
with patch('app.routes.set_label_routes.set_label_service') as mock_service:
mock_service.get_available_sets.side_effect = Exception("Test error")
# Make request
response = client.get("/api/set-labels/available-sets")
# Assert response
assert response.status_code == 500
assert response.json() == {"detail": "Test error"}