From 21408af48c81c9f86dd8c694187367d49e132308 Mon Sep 17 00:00:00 2001 From: zman Date: Thu, 17 Apr 2025 00:09:16 -0400 Subject: [PATCH] order and api and more --- ...b3f870fdf_create_tcgplayer_order_tables.py | 126 +++++++ app.log | 40 ++- .../templates/address_label_dk1201.html | 28 +- app/data/assets/templates/pull_sheet.html | 13 +- app/data/assets/templates/set_label.html | 64 ++++ app/main.py | 77 +---- app/models/order.py | 25 -- app/models/tcgplayer_order.py | 78 +++++ app/routes/order_routes.py | 214 ++++++++++++ app/routes/routes.py | 90 +---- app/routes/set_label_routes.py | 52 +++ app/schemas/tcgplayer.py | 182 ++++++++++ app/services/__init__.py | 10 +- app/services/address_label_service.py | 47 ++- app/services/base_service.py | 8 +- .../external_api/base_external_service.py | 8 +- .../external_api/mtgjson/mtgjson_service.py | 124 ++++--- .../external_api/tcgcsv/tcgcsv_service.py | 201 ++++++----- .../tcgplayer/base_tcgplayer_service.py | 39 +-- .../tcgplayer/order_management_service.py | 323 +++++++++++++++++- app/services/label_printer_service.py | 32 +- app/services/order_service.py | 58 ---- app/services/pull_sheet_service.py | 26 +- app/services/scheduler/base_scheduler.py | 26 +- app/services/scheduler/scheduler_service.py | 97 +++++- app/services/service_manager.py | 84 +++++ app/services/service_registry.py | 18 - app/services/set_label_service.py | 262 ++++++++++++++ printer_receiver.py | 18 +- tests/__init__.py | 1 + tests/test_set_label_routes.py | 95 ++++++ 31 files changed, 1924 insertions(+), 542 deletions(-) create mode 100644 alembic/versions/6f2b3f870fdf_create_tcgplayer_order_tables.py create mode 100644 app/data/assets/templates/set_label.html delete mode 100644 app/models/order.py create mode 100644 app/models/tcgplayer_order.py create mode 100644 app/routes/order_routes.py create mode 100644 app/routes/set_label_routes.py create mode 100644 app/schemas/tcgplayer.py delete mode 100644 app/services/order_service.py create mode 100644 app/services/service_manager.py delete mode 100644 app/services/service_registry.py create mode 100644 app/services/set_label_service.py create mode 100644 tests/__init__.py create mode 100644 tests/test_set_label_routes.py 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 %} + {{ name }} icon + {% 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