diff --git a/alembic/versions/6f2b3f870fdf_create_tcgplayer_order_tables.py b/alembic/versions/6f2b3f870fdf_create_tcgplayer_order_tables.py
new file mode 100644
index 0000000..0b6df02
--- /dev/null
+++ b/alembic/versions/6f2b3f870fdf_create_tcgplayer_order_tables.py
@@ -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')
diff --git a/app.log b/app.log
index bb6e189..74284db 100644
--- a/app.log
+++ b/app.log
@@ -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
diff --git a/app/data/assets/templates/address_label_dk1201.html b/app/data/assets/templates/address_label_dk1201.html
index 37051f6..10dd5f9 100644
--- a/app/data/assets/templates/address_label_dk1201.html
+++ b/app/data/assets/templates/address_label_dk1201.html
@@ -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;
}
@@ -61,8 +62,7 @@ body {
{{ recipient_name }}
-
{{ address_line1 }}
- {% if address_line2 %}
{{ address_line2 }}
{% endif %}
+
{{ address_line1 }}{% if address_line2 %} {{ address_line2 }}{% endif %}
{{ city }}, {{ state }} {{ zip_code }}
diff --git a/app/data/assets/templates/pull_sheet.html b/app/data/assets/templates/pull_sheet.html
index 68ca5ab..d269a79 100644
--- a/app/data/assets/templates/pull_sheet.html
+++ b/app/data/assets/templates/pull_sheet.html
@@ -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 {
Product Name |
- Condition |
Qty |
Set |
Rarity |
+ Card # |
{% for item in items %}
{{ item.product_name }} |
- {{ item.condition }} |
{{ item.quantity }} |
{{ item.set }} |
{{ item.rarity }} |
+ {{ item.card_number }} |
{% endfor %}
diff --git a/app/data/assets/templates/set_label.html b/app/data/assets/templates/set_label.html
new file mode 100644
index 0000000..b3d446c
--- /dev/null
+++ b/app/data/assets/templates/set_label.html
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+ {% if icon_b64 %}
+

+ {% endif %}
+
+
{{ name }}
+
{{ code }}
+
{{ date.strftime('%B %Y') }}
+
+
+
+
\ No newline at end of file
diff --git a/app/main.py b/app/main.py
index e1534f9..a8036ed 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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",
diff --git a/app/models/order.py b/app/models/order.py
deleted file mode 100644
index 5369437..0000000
--- a/app/models/order.py
+++ /dev/null
@@ -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)
\ No newline at end of file
diff --git a/app/models/tcgplayer_order.py b/app/models/tcgplayer_order.py
new file mode 100644
index 0000000..d5c9e1f
--- /dev/null
+++ b/app/models/tcgplayer_order.py
@@ -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))
diff --git a/app/routes/order_routes.py b/app/routes/order_routes.py
new file mode 100644
index 0000000..1e967bc
--- /dev/null
+++ b/app/routes/order_routes.py
@@ -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)}")
\ No newline at end of file
diff --git a/app/routes/routes.py b/app/routes/routes.py
index 8a09518..36cf416 100644
--- a/app/routes/routes.py
+++ b/app/routes/routes.py
@@ -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
# ============================================================================
diff --git a/app/routes/set_label_routes.py b/app/routes/set_label_routes.py
new file mode 100644
index 0000000..e5c96c4
--- /dev/null
+++ b/app/routes/set_label_routes.py
@@ -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))
\ No newline at end of file
diff --git a/app/schemas/tcgplayer.py b/app/schemas/tcgplayer.py
new file mode 100644
index 0000000..b35a6e3
--- /dev/null
+++ b/app/schemas/tcgplayer.py
@@ -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
\ No newline at end of file
diff --git a/app/services/__init__.py b/app/services/__init__.py
index d99ec72..7c4e3de 100644
--- a/app/services/__init__.py
+++ b/app/services/__init__.py
@@ -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'
]
\ No newline at end of file
diff --git a/app/services/address_label_service.py b/app/services/address_label_service.py
index 788667d..35cc0e7 100644
--- a/app/services/address_label_service.py
+++ b/app/services/address_label_service.py
@@ -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
\ No newline at end of file
diff --git a/app/services/base_service.py b/app/services/base_service.py
index 8edeb58..86bb6a1 100644
--- a/app/services/base_service.py
+++ b/app/services/base_service.py
@@ -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)
\ No newline at end of file
+ return False
\ No newline at end of file
diff --git a/app/services/external_api/base_external_service.py b/app/services/external_api/base_external_service.py
index b9ccf76..4abd051 100644
--- a/app/services/external_api/base_external_service.py
+++ b/app/services/external_api/base_external_service.py
@@ -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()
\ No newline at end of file
+ await self.session.close()
+ self.session = None
+ logger.info(f"Closed session for {self.__class__.__name__}")
\ No newline at end of file
diff --git a/app/services/external_api/mtgjson/mtgjson_service.py b/app/services/external_api/mtgjson/mtgjson_service.py
index 5beb6c6..53315ea 100644
--- a/app/services/external_api/mtgjson/mtgjson_service.py
+++ b/app/services/external_api/mtgjson/mtgjson_service.py
@@ -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]:
diff --git a/app/services/external_api/tcgcsv/tcgcsv_service.py b/app/services/external_api/tcgcsv/tcgcsv_service.py
index 93761a5..5942ca9 100644
--- a/app/services/external_api/tcgcsv/tcgcsv_service.py
+++ b/app/services/external_api/tcgcsv/tcgcsv_service.py
@@ -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
diff --git a/app/services/external_api/tcgplayer/base_tcgplayer_service.py b/app/services/external_api/tcgplayer/base_tcgplayer_service.py
index 2b31311..8a59e79 100644
--- a/app/services/external_api/tcgplayer/base_tcgplayer_service.py
+++ b/app/services/external_api/tcgplayer/base_tcgplayer_service.py
@@ -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()
\ No newline at end of file
+ raise
\ No newline at end of file
diff --git a/app/services/external_api/tcgplayer/order_management_service.py b/app/services/external_api/tcgplayer/order_management_service.py
index 16b6037..b0da513 100644
--- a/app/services/external_api/tcgplayer/order_management_service.py
+++ b/app/services/external_api/tcgplayer/order_management_service.py
@@ -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
\ No newline at end of file
+ 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
+
\ No newline at end of file
diff --git a/app/services/label_printer_service.py b/app/services/label_printer_service.py
index d703ddf..04f820a 100644
--- a/app/services/label_printer_service.py
+++ b/app/services/label_printer_service.py
@@ -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
diff --git a/app/services/order_service.py b/app/services/order_service.py
deleted file mode 100644
index de9f477..0000000
--- a/app/services/order_service.py
+++ /dev/null
@@ -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
\ No newline at end of file
diff --git a/app/services/pull_sheet_service.py b/app/services/pull_sheet_service.py
index 8b7a89b..791fa26 100644
--- a/app/services/pull_sheet_service.py
+++ b/app/services/pull_sheet_service.py
@@ -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
\ No newline at end of file
diff --git a/app/services/scheduler/base_scheduler.py b/app/services/scheduler/base_scheduler.py
index 792f414..a8958f2 100644
--- a/app/services/scheduler/base_scheduler.py
+++ b/app/services/scheduler/base_scheduler.py
@@ -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
\ No newline at end of file
diff --git a/app/services/scheduler/scheduler_service.py b/app/services/scheduler/scheduler_service.py
index 187e0d5..11d2e9c 100644
--- a/app/services/scheduler/scheduler_service.py
+++ b/app/services/scheduler/scheduler_service.py
@@ -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")
\ No newline at end of file
+ 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
\ No newline at end of file
diff --git a/app/services/service_manager.py b/app/services/service_manager.py
new file mode 100644
index 0000000..51b9368
--- /dev/null
+++ b/app/services/service_manager.py
@@ -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]
\ No newline at end of file
diff --git a/app/services/service_registry.py b/app/services/service_registry.py
deleted file mode 100644
index ec3d95d..0000000
--- a/app/services/service_registry.py
+++ /dev/null
@@ -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()
\ No newline at end of file
diff --git a/app/services/set_label_service.py b/app/services/set_label_service.py
new file mode 100644
index 0000000..430ac9e
--- /dev/null
+++ b/app/services/set_label_service.py
@@ -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()
\ No newline at end of file
diff --git a/printer_receiver.py b/printer_receiver.py
index 8db0274..1334e70 100644
--- a/printer_receiver.py
+++ b/printer_receiver.py
@@ -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}")
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..ac17fb9
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+# This file makes the tests directory a Python package
\ No newline at end of file
diff --git a/tests/test_set_label_routes.py b/tests/test_set_label_routes.py
new file mode 100644
index 0000000..be53e80
--- /dev/null
+++ b/tests/test_set_label_routes.py
@@ -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"}
\ No newline at end of file