order and api and more
This commit is contained in:
parent
593e8960b7
commit
21408af48c
126
alembic/versions/6f2b3f870fdf_create_tcgplayer_order_tables.py
Normal file
126
alembic/versions/6f2b3f870fdf_create_tcgplayer_order_tables.py
Normal file
@ -0,0 +1,126 @@
|
||||
"""create_tcgplayer_order_tables
|
||||
|
||||
Revision ID: 6f2b3f870fdf
|
||||
Revises: fix_foreign_key_issue
|
||||
Create Date: 2025-04-16 20:19:01.698636
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSON
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '6f2b3f870fdf'
|
||||
down_revision: Union[str, None] = 'fix_foreign_key_issue'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create tcgplayer_orders table
|
||||
op.create_table(
|
||||
'tcgplayer_orders',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('order_number', sa.String(), nullable=True),
|
||||
sa.Column('order_created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('status', sa.String(), nullable=True),
|
||||
sa.Column('channel', sa.String(), nullable=True),
|
||||
sa.Column('fulfillment', sa.String(), nullable=True),
|
||||
sa.Column('seller_name', sa.String(), nullable=True),
|
||||
sa.Column('buyer_name', sa.String(), nullable=True),
|
||||
sa.Column('payment_type', sa.String(), nullable=True),
|
||||
sa.Column('pickup_status', sa.String(), nullable=True),
|
||||
sa.Column('shipping_type', sa.String(), nullable=True),
|
||||
sa.Column('estimated_delivery_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('recipient_name', sa.String(), nullable=True),
|
||||
sa.Column('address_line_1', sa.String(), nullable=True),
|
||||
sa.Column('address_line_2', sa.String(), nullable=True),
|
||||
sa.Column('city', sa.String(), nullable=True),
|
||||
sa.Column('state', sa.String(), nullable=True),
|
||||
sa.Column('zip_code', sa.String(), nullable=True),
|
||||
sa.Column('country', sa.String(), nullable=True),
|
||||
sa.Column('tracking_numbers', JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tcgplayer_orders_id'), 'tcgplayer_orders', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_tcgplayer_orders_order_number'), 'tcgplayer_orders', ['order_number'], unique=False)
|
||||
|
||||
# Create tcgplayer_order_transactions table
|
||||
op.create_table(
|
||||
'tcgplayer_order_transactions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('order_number', sa.String(), nullable=True),
|
||||
sa.Column('product_amount', sa.Float(), nullable=True),
|
||||
sa.Column('shipping_amount', sa.Float(), nullable=True),
|
||||
sa.Column('gross_amount', sa.Float(), nullable=True),
|
||||
sa.Column('fee_amount', sa.Float(), nullable=True),
|
||||
sa.Column('net_amount', sa.Float(), nullable=True),
|
||||
sa.Column('direct_fee_amount', sa.Float(), nullable=True),
|
||||
sa.Column('taxes', JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tcgplayer_order_transactions_id'), 'tcgplayer_order_transactions', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_tcgplayer_order_transactions_order_number'), 'tcgplayer_order_transactions', ['order_number'], unique=False)
|
||||
|
||||
# Create tcgplayer_order_products table
|
||||
op.create_table(
|
||||
'tcgplayer_order_products',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('order_number', sa.String(), nullable=True),
|
||||
sa.Column('product_name', sa.String(), nullable=True),
|
||||
sa.Column('unit_price', sa.Float(), nullable=True),
|
||||
sa.Column('extended_price', sa.Float(), nullable=True),
|
||||
sa.Column('quantity', sa.Integer(), nullable=True),
|
||||
sa.Column('url', sa.String(), nullable=True),
|
||||
sa.Column('product_id', sa.String(), nullable=True),
|
||||
sa.Column('sku_id', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tcgplayer_order_products_id'), 'tcgplayer_order_products', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_tcgplayer_order_products_order_number'), 'tcgplayer_order_products', ['order_number'], unique=False)
|
||||
|
||||
# Create tcgplayer_order_refunds table
|
||||
op.create_table(
|
||||
'tcgplayer_order_refunds',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('order_number', sa.String(), nullable=True),
|
||||
sa.Column('refund_created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('type', sa.String(), nullable=True),
|
||||
sa.Column('amount', sa.Float(), nullable=True),
|
||||
sa.Column('description', sa.String(), nullable=True),
|
||||
sa.Column('origin', sa.String(), nullable=True),
|
||||
sa.Column('shipping_amount', sa.Float(), nullable=True),
|
||||
sa.Column('products', JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tcgplayer_order_refunds_id'), 'tcgplayer_order_refunds', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_tcgplayer_order_refunds_order_number'), 'tcgplayer_order_refunds', ['order_number'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_tcgplayer_order_refunds_order_number'), table_name='tcgplayer_order_refunds')
|
||||
op.drop_index(op.f('ix_tcgplayer_order_refunds_id'), table_name='tcgplayer_order_refunds')
|
||||
op.drop_table('tcgplayer_order_refunds')
|
||||
|
||||
op.drop_index(op.f('ix_tcgplayer_order_products_order_number'), table_name='tcgplayer_order_products')
|
||||
op.drop_index(op.f('ix_tcgplayer_order_products_id'), table_name='tcgplayer_order_products')
|
||||
op.drop_table('tcgplayer_order_products')
|
||||
|
||||
op.drop_index(op.f('ix_tcgplayer_order_transactions_order_number'), table_name='tcgplayer_order_transactions')
|
||||
op.drop_index(op.f('ix_tcgplayer_order_transactions_id'), table_name='tcgplayer_order_transactions')
|
||||
op.drop_table('tcgplayer_order_transactions')
|
||||
|
||||
op.drop_index(op.f('ix_tcgplayer_orders_order_number'), table_name='tcgplayer_orders')
|
||||
op.drop_index(op.f('ix_tcgplayer_orders_id'), table_name='tcgplayer_orders')
|
||||
op.drop_table('tcgplayer_orders')
|
40
app.log
40
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
|
||||
|
@ -24,35 +24,36 @@ body {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding: 2mm;
|
||||
padding: 1mm;
|
||||
page-break-after: always;
|
||||
}
|
||||
|
||||
/* Main address centered */
|
||||
.address {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 85mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 88mm;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recipient-name {
|
||||
font-size: 10pt;
|
||||
font-weight: bold;
|
||||
font-size: 12pt;
|
||||
margin-bottom: 1mm;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.address-line {
|
||||
font-size: 9pt;
|
||||
line-height: 1.2;
|
||||
font-size: 11pt;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 0.5mm;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.city-line {
|
||||
font-size: 9pt;
|
||||
font-weight: bold;
|
||||
font-size: 11pt;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
@ -61,8 +62,7 @@ body {
|
||||
<div class="label-container">
|
||||
<div class="address">
|
||||
<div class="recipient-name">{{ recipient_name }}</div>
|
||||
<div class="address-line">{{ address_line1 }}</div>
|
||||
{% if address_line2 %}<div class="address-line">{{ address_line2 }}</div>{% endif %}
|
||||
<div class="address-line">{{ address_line1 }}{% if address_line2 %} {{ address_line2 }}{% endif %}</div>
|
||||
<div class="city-line">{{ city }}, {{ state }} {{ zip_code }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -80,15 +80,16 @@ tr:hover {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.condition {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.rarity {
|
||||
width: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-number {
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
width: 200px;
|
||||
}
|
||||
@ -114,20 +115,20 @@ tbody tr:hover {
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="product-name">Product Name</th>
|
||||
<th class="condition">Condition</th>
|
||||
<th class="quantity">Qty</th>
|
||||
<th class="set">Set</th>
|
||||
<th class="rarity">Rarity</th>
|
||||
<th class="card-number">Card #</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr class="{{ 'foil' if 'Foil' in item.condition else '' }} {{ 'multiple' if item.quantity|int > 1 else '' }}">
|
||||
<td class="product-name">{{ item.product_name }}</td>
|
||||
<td class="condition">{{ item.condition }}</td>
|
||||
<td class="quantity">{{ item.quantity }}</td>
|
||||
<td class="set">{{ item.set }}</td>
|
||||
<td class="rarity">{{ item.rarity }}</td>
|
||||
<td class="card-number">{{ item.card_number }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
64
app/data/assets/templates/set_label.html
Normal file
64
app/data/assets/templates/set_label.html
Normal file
@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
@page {
|
||||
size: 90mm 29mm;
|
||||
margin: 0;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
.label {
|
||||
width: 90mm;
|
||||
height: 29mm;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 1mm 2mm;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.icon {
|
||||
width: 12mm;
|
||||
height: 12mm;
|
||||
margin-right: 3mm;
|
||||
margin-top: 1mm;
|
||||
}
|
||||
.info {
|
||||
flex: 1;
|
||||
padding-top: 1mm;
|
||||
}
|
||||
.name {
|
||||
font-size: 12pt;
|
||||
font-weight: bold;
|
||||
margin-bottom: 2mm;
|
||||
color: #000;
|
||||
}
|
||||
.code {
|
||||
font-size: 11pt;
|
||||
font-weight: bold;
|
||||
margin-bottom: 2mm;
|
||||
color: #000;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.date {
|
||||
font-size: 10pt;
|
||||
color: #000;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="label">
|
||||
{% if icon_b64 %}
|
||||
<img class="icon" src="data:image/svg+xml;base64,{{ icon_b64 }}" alt="{{ name }} icon">
|
||||
{% endif %}
|
||||
<div class="info">
|
||||
<div class="name">{{ name }}</div>
|
||||
<div class="code">{{ code }}</div>
|
||||
<div class="date">{{ date.strftime('%B %Y') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
77
app/main.py
77
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",
|
||||
|
@ -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)
|
78
app/models/tcgplayer_order.py
Normal file
78
app/models/tcgplayer_order.py
Normal file
@ -0,0 +1,78 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, UTC
|
||||
from app.db.database import Base
|
||||
|
||||
class TCGPlayerOrder(Base):
|
||||
__tablename__ = "tcgplayer_orders"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_number = Column(String, index=True)
|
||||
order_created_at = Column(DateTime)
|
||||
status = Column(String)
|
||||
channel = Column(String)
|
||||
fulfillment = Column(String)
|
||||
seller_name = Column(String)
|
||||
buyer_name = Column(String)
|
||||
payment_type = Column(String)
|
||||
pickup_status = Column(String)
|
||||
shipping_type = Column(String)
|
||||
estimated_delivery_date = Column(DateTime)
|
||||
recipient_name = Column(String)
|
||||
address_line_1 = Column(String)
|
||||
address_line_2 = Column(String)
|
||||
city = Column(String)
|
||||
state = Column(String)
|
||||
zip_code = Column(String)
|
||||
country = Column(String)
|
||||
tracking_numbers = Column(JSON)
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class TCGPlayerOrderTransaction(Base):
|
||||
__tablename__ = "tcgplayer_order_transactions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_number = Column(String, index=True)
|
||||
product_amount = Column(Float)
|
||||
shipping_amount = Column(Float)
|
||||
gross_amount = Column(Float)
|
||||
fee_amount = Column(Float)
|
||||
net_amount = Column(Float)
|
||||
direct_fee_amount = Column(Float)
|
||||
taxes = Column(JSON)
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class TCGPlayerOrderProduct(Base):
|
||||
__tablename__ = "tcgplayer_order_products"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_number = Column(String, index=True)
|
||||
product_name = Column(String)
|
||||
unit_price = Column(Float)
|
||||
extended_price = Column(Float)
|
||||
quantity = Column(Integer)
|
||||
url = Column(String)
|
||||
product_id = Column(String)
|
||||
sku_id = Column(String)
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||
|
||||
class TCGPlayerOrderRefund(Base):
|
||||
__tablename__ = "tcgplayer_order_refunds"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_number = Column(String, index=True)
|
||||
refund_created_at = Column(DateTime)
|
||||
type = Column(String)
|
||||
amount = Column(Float)
|
||||
type = Column(String)
|
||||
description = Column(String)
|
||||
origin = Column(String)
|
||||
shipping_amount = Column(Float)
|
||||
products = Column(JSON)
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
214
app/routes/order_routes.py
Normal file
214
app/routes/order_routes.py
Normal file
@ -0,0 +1,214 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from app.services.external_api.tcgplayer.order_management_service import OrderManagementService
|
||||
from app.services.label_printer_service import LabelPrinterService
|
||||
from app.services.regular_printer_service import RegularPrinterService
|
||||
from app.services.address_label_service import AddressLabelService
|
||||
from typing import List, Optional, Literal
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
from enum import Enum
|
||||
from app.schemas.tcgplayer import TCGPlayerAPIOrderSummary, TCGPlayerAPIOrder
|
||||
from app.services.service_manager import ServiceManager
|
||||
|
||||
|
||||
|
||||
class SearchRange(str, Enum):
|
||||
LAST_WEEK = "LastWeek"
|
||||
LAST_MONTH = "LastMonth"
|
||||
LAST_THREE_MONTHS = "LastThreeMonths"
|
||||
LAST_FOUR_MONTHS = "LastFourMonths"
|
||||
LAST_TWO_YEARS = "LastTwoYears"
|
||||
|
||||
class LabelType(str, Enum):
|
||||
DK1201 = "dk1201"
|
||||
DK1241 = "dk1241"
|
||||
|
||||
# Initialize service manager
|
||||
service_manager = ServiceManager()
|
||||
|
||||
# Initialize router
|
||||
router = APIRouter(prefix="/orders")
|
||||
|
||||
@router.get("/", response_model=List[TCGPlayerAPIOrderSummary])
|
||||
async def get_orders(
|
||||
search_range: SearchRange = SearchRange.LAST_THREE_MONTHS,
|
||||
open_only: bool = False
|
||||
) -> List[TCGPlayerAPIOrderSummary]:
|
||||
"""
|
||||
Retrieve orders from TCGPlayer based on search criteria.
|
||||
|
||||
Args:
|
||||
search_range: Time range to search for orders
|
||||
open_only: Whether to only return open orders
|
||||
|
||||
Returns:
|
||||
List of orders matching the search criteria
|
||||
"""
|
||||
try:
|
||||
order_management = service_manager.get_service('order_management')
|
||||
orders = await order_management.get_orders(search_range, open_only)
|
||||
return orders
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch orders: {str(e)}")
|
||||
|
||||
@router.get("/{order_id}", response_model=TCGPlayerAPIOrder)
|
||||
async def get_order(order_id: str) -> TCGPlayerAPIOrder:
|
||||
"""
|
||||
Retrieve a specific order by ID.
|
||||
|
||||
Args:
|
||||
order_id: The TCGPlayer order number
|
||||
|
||||
Returns:
|
||||
The requested order details
|
||||
"""
|
||||
try:
|
||||
order_management = service_manager.get_service('order_management')
|
||||
order = await order_management.get_order(order_id)
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail=f"Order {order_id} not found")
|
||||
return order
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch order: {str(e)}")
|
||||
|
||||
@router.post("/generate-pull-sheets")
|
||||
async def generate_pull_sheets(
|
||||
order_ids: Optional[List[str]] = None,
|
||||
all_open_orders: bool = False
|
||||
) -> dict:
|
||||
"""
|
||||
Generate and print pull sheets for the specified orders.
|
||||
|
||||
Args:
|
||||
order_ids: List of TCGPlayer order numbers (optional if all_open_orders is True)
|
||||
all_open_orders: If True, generate pull sheets for all orders (ignores order_ids)
|
||||
|
||||
Returns:
|
||||
Success status of the operation
|
||||
"""
|
||||
try:
|
||||
order_management = service_manager.get_service('order_management')
|
||||
if not all_open_orders and not order_ids:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Either order_ids must be provided or all_open_orders must be True"
|
||||
)
|
||||
|
||||
if all_open_orders:
|
||||
order_ids = await order_management.get_order_ids(search_range="LastWeek", open_only=True)
|
||||
|
||||
pull_sheet = await order_management.get_pull_sheet(order_ids)
|
||||
pull_sheet_file = await order_management.save_file(
|
||||
pull_sheet,
|
||||
f"pull_sheet_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.csv"
|
||||
)
|
||||
pull_sheet_service = service_manager.get_service('pull_sheet')
|
||||
pull_sheet_pdf = await pull_sheet_service.generate_pull_sheet_pdf(pull_sheet_file)
|
||||
regular_printer = service_manager.get_service('regular_printer')
|
||||
success = await regular_printer.print_file(pull_sheet_pdf)
|
||||
return {"success": success}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to generate pull sheet: {str(e)}")
|
||||
|
||||
@router.post("/generate-packing-slips")
|
||||
async def generate_packing_slips(
|
||||
order_ids: Optional[List[str]] = None,
|
||||
all_open_orders: bool = False
|
||||
) -> dict:
|
||||
"""
|
||||
Generate and print packing slips for the specified orders.
|
||||
|
||||
Args:
|
||||
order_ids: List of TCGPlayer order numbers (optional if all_open_orders is True)
|
||||
all_open_orders: If True, generate packing slips for all orders (ignores order_ids)
|
||||
|
||||
Returns:
|
||||
Success status of the operation
|
||||
"""
|
||||
try:
|
||||
if not all_open_orders and not order_ids:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Either order_ids must be provided or all_open_orders must be True"
|
||||
)
|
||||
|
||||
# TODO: Add logic to fetch all orders when all_open_orders is True
|
||||
if all_open_orders:
|
||||
order_management = service_manager.get_service('order_management')
|
||||
order_ids = await order_management.get_order_ids(search_range="LastWeek", open_only=True)
|
||||
|
||||
packing_slip = await order_management.get_packing_slip(order_ids)
|
||||
packing_slip_file = await order_management.save_file(
|
||||
packing_slip,
|
||||
f"packing_slip_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.pdf"
|
||||
)
|
||||
label_printer = service_manager.get_service('label_printer')
|
||||
success = await label_printer.print_file(
|
||||
packing_slip_file,
|
||||
label_size="dk1241",
|
||||
label_type="packing_slip"
|
||||
)
|
||||
return {"success": success}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to generate packing slip: {str(e)}")
|
||||
|
||||
@router.post("/generate-address-labels")
|
||||
async def generate_address_labels(
|
||||
order_ids: Optional[List[str]] = None,
|
||||
all_open_orders: bool = False,
|
||||
label_type: LabelType = LabelType.DK1201
|
||||
) -> dict:
|
||||
"""
|
||||
Generate and print address labels for the specified orders.
|
||||
|
||||
Args:
|
||||
order_ids: List of TCGPlayer order numbers (optional if all_open_orders is True)
|
||||
all_open_orders: If True, generate address labels for all orders (ignores order_ids)
|
||||
label_type: Type of label to generate (dk1201 or dk1241)
|
||||
|
||||
Returns:
|
||||
Success status of the operation
|
||||
"""
|
||||
try:
|
||||
order_management = service_manager.get_service('order_management')
|
||||
if not all_open_orders and not order_ids:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Either order_ids must be provided or all_open_orders must be True"
|
||||
)
|
||||
|
||||
if all_open_orders:
|
||||
order_ids = await order_management.get_order_ids(search_range="LastWeek", open_only=True)
|
||||
|
||||
shipping_csv = await order_management.get_shipping_csv(order_ids)
|
||||
shipping_csv_file = await order_management.save_file(
|
||||
shipping_csv,
|
||||
f"shipping_csv_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.csv"
|
||||
)
|
||||
|
||||
# Generate PDFs
|
||||
address_label = service_manager.get_service('address_label')
|
||||
pdf_files = await address_label.generate_labels_from_csv(
|
||||
shipping_csv_file,
|
||||
label_type=label_type
|
||||
)
|
||||
|
||||
# Print each PDF
|
||||
label_printer = service_manager.get_service('label_printer')
|
||||
for pdf_file in pdf_files:
|
||||
success = await label_printer.print_file(
|
||||
pdf_file,
|
||||
label_size=label_type,
|
||||
label_type="address_label"
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to print address label for file {pdf_file}"
|
||||
)
|
||||
|
||||
return {"success": True, "message": "Address labels generated and printed successfully"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to generate address labels: {str(e)}")
|
@ -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
|
||||
# ============================================================================
|
||||
|
52
app/routes/set_label_routes.py
Normal file
52
app/routes/set_label_routes.py
Normal file
@ -0,0 +1,52 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from app.services.set_label_service import SetLabelService
|
||||
from app.services.label_printer_service import LabelPrinterService
|
||||
from typing import List, Optional
|
||||
import asyncio
|
||||
from app.db.database import get_db
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
class SetLabelRequest(BaseModel):
|
||||
sets: List[str]
|
||||
|
||||
router = APIRouter(prefix="/set-labels")
|
||||
set_label_service = SetLabelService()
|
||||
label_printer_service = LabelPrinterService(printer_api_url="http://192.168.1.110:8000")
|
||||
|
||||
@router.post("/generate")
|
||||
async def generate_set_labels(request: SetLabelRequest):
|
||||
"""
|
||||
Generate PDF labels for the specified MTG sets.
|
||||
|
||||
Args:
|
||||
request: Request body containing list of set codes to generate labels for
|
||||
|
||||
Returns:
|
||||
Message indicating success or failure
|
||||
"""
|
||||
try:
|
||||
set_pdfs = await set_label_service.generate_labels(request.sets)
|
||||
for set_pdf in set_pdfs:
|
||||
success = await label_printer_service.print_file(set_pdf, label_size="dk1201", label_type="set_label")
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to print")
|
||||
return {
|
||||
"message": "Labels printed successfully"
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/available-sets")
|
||||
async def get_available_sets(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get a list of available MTG sets that can be used for label generation.
|
||||
|
||||
Returns:
|
||||
List of set codes and their names
|
||||
"""
|
||||
try:
|
||||
sets = await set_label_service.get_available_sets(db)
|
||||
return sets
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
182
app/schemas/tcgplayer.py
Normal file
182
app/schemas/tcgplayer.py
Normal file
@ -0,0 +1,182 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# API Response Schemas (matching TCGPlayer API structure)
|
||||
class TCGPlayerAPIOrderProduct(BaseModel):
|
||||
name: str
|
||||
unitPrice: float
|
||||
extendedPrice: float
|
||||
quantity: int
|
||||
url: str
|
||||
productId: str
|
||||
skuId: str
|
||||
|
||||
class TCGPlayerAPIOrderTax(BaseModel):
|
||||
code: str
|
||||
amount: float
|
||||
|
||||
class TCGPlayerAPIOrderTransaction(BaseModel):
|
||||
productAmount: float
|
||||
shippingAmount: float
|
||||
grossAmount: float
|
||||
feeAmount: float
|
||||
netAmount: float
|
||||
directFeeAmount: float
|
||||
taxes: List[TCGPlayerAPIOrderTax]
|
||||
|
||||
class TCGPlayerAPIOrderRefund(BaseModel):
|
||||
createdAt: datetime
|
||||
type: str
|
||||
amount: float
|
||||
description: Optional[str] = None
|
||||
origin: str
|
||||
shippingAmount: float
|
||||
products: List[dict]
|
||||
|
||||
class TCGPlayerAPIOrderShippingAddress(BaseModel):
|
||||
recipientName: str
|
||||
addressOne: str
|
||||
addressTwo: Optional[str] = None
|
||||
city: str
|
||||
territory: str
|
||||
country: str
|
||||
postalCode: str
|
||||
|
||||
class TCGPlayerAPIOrderTrackingNumber(BaseModel):
|
||||
createdAt: str
|
||||
carrier: str
|
||||
trackingNumber: str
|
||||
status: str
|
||||
|
||||
class TCGPlayerAPIOrder(BaseModel):
|
||||
createdAt: str
|
||||
status: str
|
||||
orderChannel: str
|
||||
orderFulfillment: str
|
||||
orderNumber: str
|
||||
sellerName: str
|
||||
buyerName: str
|
||||
paymentType: str
|
||||
pickupStatus: str
|
||||
shippingType: str
|
||||
estimatedDeliveryDate: datetime
|
||||
transaction: TCGPlayerAPIOrderTransaction
|
||||
shippingAddress: TCGPlayerAPIOrderShippingAddress
|
||||
products: List[TCGPlayerAPIOrderProduct]
|
||||
refunds: Optional[List[TCGPlayerAPIOrderRefund]]
|
||||
refundStatus: str
|
||||
trackingNumbers: List[TCGPlayerAPIOrderTrackingNumber]
|
||||
allowedActions: List[str]
|
||||
|
||||
class TCGPlayerAPIOrderSummary(BaseModel):
|
||||
orderNumber: str
|
||||
orderDate: datetime
|
||||
orderChannel: str
|
||||
orderStatus: str
|
||||
buyerName: str
|
||||
shippingType: str
|
||||
productAmount: float
|
||||
shippingAmount: float
|
||||
totalAmount: float
|
||||
buyerPaid: bool
|
||||
orderFulfillment: str
|
||||
|
||||
class TCGPlayerAPIOrderSearchResponse(BaseModel):
|
||||
totalOrders: int
|
||||
orders: List[TCGPlayerAPIOrderSummary]
|
||||
# Database Schemas (matching your internal structure)
|
||||
class TCGPlayerOrderBase(BaseModel):
|
||||
order_number: str
|
||||
order_created_at: datetime
|
||||
status: str
|
||||
channel: str
|
||||
fulfillment: str
|
||||
seller_name: str
|
||||
buyer_name: str
|
||||
payment_type: str
|
||||
pickup_status: str
|
||||
shipping_type: str
|
||||
estimated_delivery_date: Optional[datetime]
|
||||
recipient_name: str
|
||||
address_line_1: str
|
||||
address_line_2: Optional[str]
|
||||
city: str
|
||||
state: str
|
||||
zip_code: str
|
||||
country: str
|
||||
tracking_numbers: List[str]
|
||||
|
||||
class TCGPlayerOrderCreate(TCGPlayerOrderBase):
|
||||
pass
|
||||
|
||||
class TCGPlayerOrder(TCGPlayerOrderBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class TCGPlayerOrderTransactionBase(BaseModel):
|
||||
order_number: str
|
||||
product_amount: float
|
||||
shipping_amount: float
|
||||
gross_amount: float
|
||||
fee_amount: float
|
||||
net_amount: float
|
||||
direct_fee_amount: float
|
||||
taxes: List[dict]
|
||||
|
||||
class TCGPlayerOrderTransactionCreate(TCGPlayerOrderTransactionBase):
|
||||
pass
|
||||
|
||||
class TCGPlayerOrderTransaction(TCGPlayerOrderTransactionBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class TCGPlayerOrderProductBase(BaseModel):
|
||||
order_number: str
|
||||
product_name: str
|
||||
unit_price: float
|
||||
extended_price: float
|
||||
quantity: int
|
||||
url: str
|
||||
product_id: str
|
||||
sku_id: str
|
||||
|
||||
class TCGPlayerOrderProductCreate(TCGPlayerOrderProductBase):
|
||||
pass
|
||||
|
||||
class TCGPlayerOrderProduct(TCGPlayerOrderProductBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class TCGPlayerOrderRefundBase(BaseModel):
|
||||
order_number: str
|
||||
refund_created_at: datetime
|
||||
type: str
|
||||
amount: float
|
||||
description: Optional[str] = None
|
||||
origin: str
|
||||
shipping_amount: float
|
||||
products: List[dict]
|
||||
|
||||
class TCGPlayerOrderRefundCreate(TCGPlayerOrderRefundBase):
|
||||
pass
|
||||
|
||||
class TCGPlayerOrderRefund(TCGPlayerOrderRefundBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
@ -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'
|
||||
]
|
@ -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
|
@ -1,15 +1,12 @@
|
||||
from typing import Type, TypeVar, Generic, List, Optional, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import Base
|
||||
from app.services.service_registry import ServiceRegistry
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
class BaseService(Generic[T]):
|
||||
def __init__(self, model: Type[T]):
|
||||
self.model = model
|
||||
# Register the service instance
|
||||
ServiceRegistry.register(self.__class__.__name__, self)
|
||||
|
||||
def get(self, db: Session, id: int) -> Optional[T]:
|
||||
return db.query(self.model).filter(self.model.id == id).first()
|
||||
@ -39,7 +36,4 @@ class BaseService(Generic[T]):
|
||||
db.delete(obj)
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_service(self, service_name: str) -> Any:
|
||||
return ServiceRegistry.get(service_name)
|
||||
return False
|
@ -1,7 +1,6 @@
|
||||
from typing import Any, Dict, Optional, Union
|
||||
import aiohttp
|
||||
import logging
|
||||
from app.services.service_registry import ServiceRegistry
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -11,8 +10,6 @@ class BaseExternalService:
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
self.session = None
|
||||
# Register the service instance
|
||||
ServiceRegistry.register(self.__class__.__name__, self)
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self.session is None or self.session.closed:
|
||||
@ -72,5 +69,8 @@ class BaseExternalService:
|
||||
raise
|
||||
|
||||
async def close(self):
|
||||
"""Close the aiohttp session if it exists"""
|
||||
if self.session and not self.session.closed:
|
||||
await self.session.close()
|
||||
await self.session.close()
|
||||
self.session = None
|
||||
logger.info(f"Closed session for {self.__class__.__name__}")
|
@ -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]:
|
||||
|
@ -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
|
||||
|
@ -67,9 +67,6 @@ class BaseTCGPlayerService(BaseExternalService):
|
||||
auth_required: bool = True,
|
||||
download_file: bool = False
|
||||
) -> Union[Dict[str, Any], bytes]:
|
||||
session = await self._get_session()
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
|
||||
# Get the authentication cookie if required
|
||||
if auth_required:
|
||||
cookie = self.credentials.get_cookie()
|
||||
@ -83,28 +80,26 @@ class BaseTCGPlayerService(BaseExternalService):
|
||||
request_headers["Cookie"] = cookie
|
||||
else:
|
||||
request_headers = headers or {}
|
||||
|
||||
|
||||
try:
|
||||
async with session.request(method, url, params=params, headers=request_headers, json=data) as response:
|
||||
if response.status == 401:
|
||||
raise RuntimeError("TCGPlayer authentication failed. Cookie may be invalid or expired.")
|
||||
response.raise_for_status()
|
||||
|
||||
if download_file:
|
||||
return await response.read()
|
||||
return await response.json()
|
||||
# Use the parent class's _make_request with our custom headers and binary flag
|
||||
response = await super()._make_request(
|
||||
method=method,
|
||||
endpoint=endpoint,
|
||||
params=params,
|
||||
headers=request_headers,
|
||||
data=data,
|
||||
binary=download_file
|
||||
)
|
||||
|
||||
if isinstance(response, dict) and response.get('status') == 401:
|
||||
raise RuntimeError("TCGPlayer authentication failed. Cookie may be invalid or expired.")
|
||||
|
||||
return response
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"TCGPlayer API request failed: {str(e)}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during TCGPlayer API request: {str(e)}")
|
||||
raise
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self.session is None or self.session.closed:
|
||||
self.session = aiohttp.ClientSession()
|
||||
return self.session
|
||||
|
||||
async def close(self):
|
||||
if self.session and not self.session.closed:
|
||||
await self.session.close()
|
||||
raise
|
@ -1,7 +1,26 @@
|
||||
from typing import Any, Dict, Optional, Union
|
||||
import logging
|
||||
from app.services.external_api.tcgplayer.base_tcgplayer_service import BaseTCGPlayerService
|
||||
from app.schemas.tcgplayer import (
|
||||
TCGPlayerAPIOrder,
|
||||
TCGPlayerOrderCreate,
|
||||
TCGPlayerOrderTransactionCreate,
|
||||
TCGPlayerOrderProductCreate,
|
||||
TCGPlayerOrderRefundCreate,
|
||||
TCGPlayerAPIOrderSummary,
|
||||
TCGPlayerAPIOrderSearchResponse
|
||||
)
|
||||
from app.models.tcgplayer_order import (
|
||||
TCGPlayerOrder,
|
||||
TCGPlayerOrderTransaction,
|
||||
TCGPlayerOrderProduct,
|
||||
TCGPlayerOrderRefund
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import transaction
|
||||
import os
|
||||
import csv
|
||||
import io
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -20,12 +39,20 @@ class OrderManagementService(BaseTCGPlayerService):
|
||||
self.shipping_endpoint = f"/shipping/export{self.API_VERSION}"
|
||||
|
||||
|
||||
async def get_orders(self, open_only: bool = False):
|
||||
async def get_orders(self, search_range: str = "LastThreeMonths", open_only: bool = False, filter_out: list[str] = [], filter_in: list[str] = []) -> list[TCGPlayerAPIOrderSummary]:
|
||||
"""
|
||||
search range options:
|
||||
LastWeek
|
||||
LastMonth
|
||||
LastThreeMonths
|
||||
LastFourMonths
|
||||
LastTwoYears
|
||||
"""
|
||||
search_from = 0
|
||||
orders = []
|
||||
while True:
|
||||
payload = {
|
||||
"searchRange": "LastThreeMonths",
|
||||
"searchRange": search_range,
|
||||
"filters": {
|
||||
"sellerKey": self.SELLER_KEY
|
||||
},
|
||||
@ -37,17 +64,27 @@ class OrderManagementService(BaseTCGPlayerService):
|
||||
"size": 25
|
||||
}
|
||||
if open_only:
|
||||
payload["filters"]["orderStatus"] = ["Processing","ReadyToShip","Received","Pulling","ReadyForPickup"]
|
||||
payload["filters"]["orderStatuses"] = ["Processing","ReadyToShip","Received","Pulling","ReadyForPickup"]
|
||||
payload["filters"]["fulfillmentTypes"] = ["Normal"]
|
||||
logger.info(f"Getting orders from {search_from} to {search_from + 25}")
|
||||
response = await self._make_request("POST", self.order_search_endpoint, data=payload, headers=self._get_headers("POST", "application/json"), auth_required=True)
|
||||
logger.info(f"Got {len(response.get('orders'))} orders")
|
||||
if len(response.get("orders")) == 0:
|
||||
break
|
||||
search_from += 25
|
||||
orders.extend(response.get("orders"))
|
||||
if filter_out:
|
||||
orders = [order for order in orders if order.get("orderNumber") not in filter_out]
|
||||
if filter_in:
|
||||
orders = [order for order in orders if order.get("orderNumber") in filter_in]
|
||||
return orders
|
||||
|
||||
async def get_order_ids(self, search_range: str = "LastThreeMonths", open_only: bool = False, filter_out: list[str] = [], filter_in: list[str] = []):
|
||||
orders = await self.get_orders(search_range, open_only, filter_out, filter_in)
|
||||
return [order.get("orderNumber") for order in orders]
|
||||
|
||||
async def get_order(self, order_id: str):
|
||||
response = await self._make_request("GET", f"{self.ORDER_MANAGEMENT_BASE_URL}/{order_id}{self.API_VERSION}")
|
||||
response = await self._make_request("GET", f"/{order_id}{self.API_VERSION}")
|
||||
return response
|
||||
|
||||
async def get_packing_slip(self, order_ids: list[str]):
|
||||
@ -59,6 +96,7 @@ class OrderManagementService(BaseTCGPlayerService):
|
||||
}
|
||||
response = await self._make_request("POST", self.packing_slip_endpoint, data=payload, headers=self._get_headers("POST", "application/json"), auth_required=True, download_file=True)
|
||||
return response
|
||||
|
||||
async def get_pull_sheet(self, order_ids: list[str]):
|
||||
payload = {
|
||||
"orderNumbers": order_ids,
|
||||
@ -75,10 +113,283 @@ class OrderManagementService(BaseTCGPlayerService):
|
||||
response = await self._make_request("POST", self.shipping_endpoint, data=payload, headers=self._get_headers("POST", "application/json"), auth_required=True, download_file=True)
|
||||
return response
|
||||
|
||||
async def save_file(self, file_data: bytes, file_name: str) -> str:
|
||||
async def save_file(self, file_data: Union[bytes, list[dict]], file_name: str) -> str:
|
||||
if not os.path.exists("app/data/cache/tcgplayer/orders"):
|
||||
os.makedirs("app/data/cache/tcgplayer/orders")
|
||||
file_path = f"app/data/cache/tcgplayer/orders/{file_name}"
|
||||
|
||||
if isinstance(file_data, list):
|
||||
# Convert list of dictionaries to CSV bytes
|
||||
output = io.StringIO()
|
||||
writer = csv.DictWriter(output, fieldnames=file_data[0].keys())
|
||||
writer.writeheader()
|
||||
writer.writerows(file_data)
|
||||
file_data = output.getvalue().encode('utf-8')
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(file_data)
|
||||
return file_path
|
||||
return file_path
|
||||
|
||||
async def save_order_to_db(self, order: dict, db: Session):
|
||||
# Parse API response using our API schema
|
||||
api_order = TCGPlayerAPIOrder.model_validate(order)
|
||||
|
||||
# Check if order exists
|
||||
existing_order = db.query(TCGPlayerOrder).filter(TCGPlayerOrder.order_number == api_order.orderNumber).first()
|
||||
|
||||
# Create database models
|
||||
db_order = TCGPlayerOrderCreate(
|
||||
order_number=api_order.orderNumber,
|
||||
order_created_at=api_order.createdAt,
|
||||
status=api_order.status,
|
||||
channel=api_order.orderChannel,
|
||||
fulfillment=api_order.orderFulfillment,
|
||||
seller_name=api_order.sellerName,
|
||||
buyer_name=api_order.buyerName,
|
||||
payment_type=api_order.paymentType,
|
||||
pickup_status=api_order.pickupStatus,
|
||||
shipping_type=api_order.shippingType,
|
||||
estimated_delivery_date=api_order.estimatedDeliveryDate,
|
||||
recipient_name=api_order.shippingAddress.recipientName,
|
||||
address_line_1=api_order.shippingAddress.addressOne,
|
||||
address_line_2=api_order.shippingAddress.addressTwo,
|
||||
city=api_order.shippingAddress.city,
|
||||
state=api_order.shippingAddress.territory,
|
||||
zip_code=api_order.shippingAddress.postalCode,
|
||||
country=api_order.shippingAddress.country,
|
||||
tracking_numbers=[t.trackingNumber for t in api_order.trackingNumbers]
|
||||
)
|
||||
|
||||
# Create transaction
|
||||
db_transaction = TCGPlayerOrderTransactionCreate(
|
||||
order_number=api_order.orderNumber,
|
||||
product_amount=api_order.transaction.productAmount,
|
||||
shipping_amount=api_order.transaction.shippingAmount,
|
||||
gross_amount=api_order.transaction.grossAmount,
|
||||
fee_amount=api_order.transaction.feeAmount,
|
||||
net_amount=api_order.transaction.netAmount,
|
||||
direct_fee_amount=api_order.transaction.directFeeAmount,
|
||||
taxes=[{"code": t.code, "amount": t.amount} for t in api_order.transaction.taxes]
|
||||
)
|
||||
|
||||
# Create products
|
||||
db_products = [
|
||||
TCGPlayerOrderProductCreate(
|
||||
order_number=api_order.orderNumber,
|
||||
product_name=p.name,
|
||||
unit_price=p.unitPrice,
|
||||
extended_price=p.extendedPrice,
|
||||
quantity=p.quantity,
|
||||
url=p.url,
|
||||
product_id=p.productId,
|
||||
sku_id=p.skuId
|
||||
)
|
||||
for p in api_order.products
|
||||
]
|
||||
|
||||
# Create refunds if they exist
|
||||
db_refunds = []
|
||||
if api_order.refunds:
|
||||
db_refunds = [
|
||||
TCGPlayerOrderRefundCreate(
|
||||
order_number=api_order.orderNumber,
|
||||
refund_created_at=r.createdAt,
|
||||
type=r.type,
|
||||
amount=r.amount,
|
||||
description=r.description,
|
||||
origin=r.origin,
|
||||
shipping_amount=r.shippingAmount,
|
||||
products=r.products
|
||||
)
|
||||
for r in api_order.refunds
|
||||
]
|
||||
|
||||
with transaction(db):
|
||||
try:
|
||||
if existing_order:
|
||||
# Check if order needs to be updated
|
||||
if not self._order_needs_update(existing_order, api_order, db):
|
||||
logger.info(f"Order {api_order.orderNumber} is up to date, skipping update")
|
||||
return existing_order
|
||||
|
||||
logger.info(f"Updating existing order {api_order.orderNumber}")
|
||||
# Update existing order
|
||||
for key, value in db_order.model_dump().items():
|
||||
setattr(existing_order, key, value)
|
||||
db_order_model = existing_order
|
||||
|
||||
# Update transaction
|
||||
existing_transaction = db.query(TCGPlayerOrderTransaction).filter(
|
||||
TCGPlayerOrderTransaction.order_number == api_order.orderNumber
|
||||
).first()
|
||||
if existing_transaction:
|
||||
for key, value in db_transaction.model_dump().items():
|
||||
setattr(existing_transaction, key, value)
|
||||
else:
|
||||
db_transaction_model = TCGPlayerOrderTransaction(**db_transaction.model_dump())
|
||||
db.add(db_transaction_model)
|
||||
|
||||
# Delete existing products and refunds
|
||||
db.query(TCGPlayerOrderProduct).filter(
|
||||
TCGPlayerOrderProduct.order_number == api_order.orderNumber
|
||||
).delete()
|
||||
db.query(TCGPlayerOrderRefund).filter(
|
||||
TCGPlayerOrderRefund.order_number == api_order.orderNumber
|
||||
).delete()
|
||||
else:
|
||||
logger.info(f"Creating new order {api_order.orderNumber}")
|
||||
# Create new order
|
||||
db_order_model = TCGPlayerOrder(**db_order.model_dump())
|
||||
db.add(db_order_model)
|
||||
db.flush() # Get the order ID
|
||||
|
||||
# Create transaction
|
||||
db_transaction_model = TCGPlayerOrderTransaction(**db_transaction.model_dump())
|
||||
db.add(db_transaction_model)
|
||||
|
||||
# Create products
|
||||
for product in db_products:
|
||||
db_product_model = TCGPlayerOrderProduct(**product.model_dump())
|
||||
db.add(db_product_model)
|
||||
|
||||
# Create refunds
|
||||
for refund in db_refunds:
|
||||
db_refund_model = TCGPlayerOrderRefund(**refund.model_dump())
|
||||
db.add(db_refund_model)
|
||||
|
||||
db.commit()
|
||||
return db_order_model
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving/updating order {api_order.orderNumber} to database: {str(e)}")
|
||||
raise
|
||||
|
||||
def _order_needs_update(self, existing_order: TCGPlayerOrder, new_order: TCGPlayerAPIOrder, db: Session) -> bool:
|
||||
"""
|
||||
Compare existing order data with new order data to determine if an update is needed.
|
||||
Returns True if the order needs to be updated, False otherwise.
|
||||
"""
|
||||
# Compare basic order fields
|
||||
order_fields_to_compare = [
|
||||
('status', 'status'),
|
||||
('channel', 'orderChannel'),
|
||||
('fulfillment', 'orderFulfillment'),
|
||||
('seller_name', 'sellerName'),
|
||||
('buyer_name', 'buyerName'),
|
||||
('payment_type', 'paymentType'),
|
||||
('pickup_status', 'pickupStatus'),
|
||||
('shipping_type', 'shippingType'),
|
||||
('recipient_name', 'shippingAddress.recipientName'),
|
||||
('address_line_1', 'shippingAddress.addressOne'),
|
||||
('address_line_2', 'shippingAddress.addressTwo'),
|
||||
('city', 'shippingAddress.city'),
|
||||
('state', 'shippingAddress.territory'),
|
||||
('zip_code', 'shippingAddress.postalCode'),
|
||||
('country', 'shippingAddress.country'),
|
||||
('tracking_numbers', 'trackingNumbers')
|
||||
]
|
||||
|
||||
for db_field, api_field in order_fields_to_compare:
|
||||
existing_value = getattr(existing_order, db_field)
|
||||
|
||||
# Handle nested fields
|
||||
if '.' in api_field:
|
||||
parts = api_field.split('.')
|
||||
new_value = new_order
|
||||
for part in parts:
|
||||
new_value = getattr(new_value, part)
|
||||
else:
|
||||
new_value = getattr(new_order, api_field)
|
||||
|
||||
# Handle special cases for tracking numbers
|
||||
if db_field == 'tracking_numbers':
|
||||
if set(existing_value or []) != set([t.trackingNumber for t in new_value or []]):
|
||||
return True
|
||||
continue
|
||||
|
||||
if existing_value != new_value:
|
||||
return True
|
||||
|
||||
# Compare transaction data
|
||||
existing_transaction = db.query(TCGPlayerOrderTransaction).filter(
|
||||
TCGPlayerOrderTransaction.order_number == existing_order.order_number
|
||||
).first()
|
||||
|
||||
if existing_transaction:
|
||||
transaction_fields_to_compare = [
|
||||
('product_amount', 'productAmount'),
|
||||
('shipping_amount', 'shippingAmount'),
|
||||
('gross_amount', 'grossAmount'),
|
||||
('fee_amount', 'feeAmount'),
|
||||
('net_amount', 'netAmount'),
|
||||
('direct_fee_amount', 'directFeeAmount')
|
||||
]
|
||||
|
||||
for db_field, api_field in transaction_fields_to_compare:
|
||||
if getattr(existing_transaction, db_field) != getattr(new_order.transaction, api_field):
|
||||
return True
|
||||
|
||||
# Compare taxes
|
||||
existing_taxes = sorted(existing_transaction.taxes, key=lambda x: x['code'])
|
||||
new_taxes = sorted(new_order.transaction.taxes, key=lambda x: x.code)
|
||||
if len(existing_taxes) != len(new_taxes):
|
||||
return True
|
||||
for existing_tax, new_tax in zip(existing_taxes, new_taxes):
|
||||
if existing_tax['code'] != new_tax.code or existing_tax['amount'] != new_tax.amount:
|
||||
return True
|
||||
|
||||
# Compare products
|
||||
existing_products = db.query(TCGPlayerOrderProduct).filter(
|
||||
TCGPlayerOrderProduct.order_number == existing_order.order_number
|
||||
).all()
|
||||
|
||||
if len(existing_products) != len(new_order.products):
|
||||
return True
|
||||
|
||||
# Sort products by product_id for comparison
|
||||
existing_products_sorted = sorted(existing_products, key=lambda x: x.product_id)
|
||||
new_products_sorted = sorted(new_order.products, key=lambda x: x.productId)
|
||||
|
||||
for existing_product, new_product in zip(existing_products_sorted, new_products_sorted):
|
||||
product_fields_to_compare = [
|
||||
('product_name', 'name'),
|
||||
('unit_price', 'unitPrice'),
|
||||
('extended_price', 'extendedPrice'),
|
||||
('quantity', 'quantity'),
|
||||
('url', 'url'),
|
||||
('product_id', 'productId'),
|
||||
('sku_id', 'skuId')
|
||||
]
|
||||
|
||||
for db_field, api_field in product_fields_to_compare:
|
||||
if getattr(existing_product, db_field) != getattr(new_product, api_field):
|
||||
return True
|
||||
|
||||
# Compare refunds
|
||||
existing_refunds = db.query(TCGPlayerOrderRefund).filter(
|
||||
TCGPlayerOrderRefund.order_number == existing_order.order_number
|
||||
).all()
|
||||
|
||||
if len(existing_refunds) != len(new_order.refunds or []):
|
||||
return True
|
||||
|
||||
# Sort refunds by created_at for comparison
|
||||
existing_refunds_sorted = sorted(existing_refunds, key=lambda x: x.refund_created_at)
|
||||
new_refunds_sorted = sorted(new_order.refunds or [], key=lambda x: x.createdAt)
|
||||
|
||||
for existing_refund, new_refund in zip(existing_refunds_sorted, new_refunds_sorted):
|
||||
refund_fields_to_compare = [
|
||||
('type', 'type'),
|
||||
('amount', 'amount'),
|
||||
('description', 'description'),
|
||||
('origin', 'origin'),
|
||||
('shipping_amount', 'shippingAmount'),
|
||||
('products', 'products')
|
||||
]
|
||||
|
||||
for db_field, api_field in refund_fields_to_compare:
|
||||
if getattr(existing_refund, db_field) != getattr(new_refund, api_field):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
@ -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
|
||||
|
@ -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
|
@ -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
|
@ -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
|
@ -1,7 +1,4 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import SessionLocal, transaction
|
||||
from app.services.external_api.tcgplayer.tcgplayer_inventory_service import TCGPlayerInventoryService
|
||||
from app.services.file_processing_service import FileProcessingService
|
||||
from app.db.database import get_db, transaction
|
||||
from app.services.scheduler.base_scheduler import BaseScheduler
|
||||
import logging
|
||||
|
||||
@ -9,9 +6,16 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class SchedulerService:
|
||||
def __init__(self):
|
||||
self.tcgplayer_service = TCGPlayerInventoryService()
|
||||
self.file_processor = FileProcessingService()
|
||||
self.scheduler = BaseScheduler()
|
||||
# Service manager will be set during initialization
|
||||
self._service_manager = None
|
||||
|
||||
@property
|
||||
def service_manager(self):
|
||||
if self._service_manager is None:
|
||||
from app.services.service_manager import ServiceManager
|
||||
self._service_manager = ServiceManager()
|
||||
return self._service_manager
|
||||
|
||||
async def process_tcgplayer_export(self, export_type: str = "live", use_cache: bool = False):
|
||||
"""
|
||||
@ -20,16 +24,20 @@ class SchedulerService:
|
||||
Args:
|
||||
export_type: Type of export to process (staged, live, or pricing)
|
||||
"""
|
||||
db = SessionLocal()
|
||||
db = get_db()
|
||||
try:
|
||||
logger.info(f"Starting scheduled TCGPlayer export processing for {export_type}")
|
||||
|
||||
# Get services
|
||||
tcgplayer_service = self.service_manager.get_service('tcgplayer_inventory')
|
||||
file_processor = self.service_manager.get_service('file_processing')
|
||||
|
||||
# Download the file
|
||||
file_bytes = await self.tcgplayer_service.get_tcgplayer_export(export_type)
|
||||
file_bytes = await tcgplayer_service.get_tcgplayer_export(export_type)
|
||||
|
||||
# Process the file and load into database
|
||||
with transaction(db):
|
||||
stats = await self.file_processor.process_tcgplayer_export(db, export_type=export_type, file_bytes=file_bytes, use_cache=use_cache)
|
||||
stats = await file_processor.process_tcgplayer_export(db, export_type=export_type, file_bytes=file_bytes, use_cache=use_cache)
|
||||
|
||||
logger.info(f"Completed TCGPlayer export processing: {stats}")
|
||||
return stats
|
||||
@ -37,9 +45,53 @@ class SchedulerService:
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing TCGPlayer export: {str(e)}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def update_open_orders_hourly(self):
|
||||
"""
|
||||
Hourly update of orders from TCGPlayer API to database
|
||||
"""
|
||||
db = get_db()
|
||||
try:
|
||||
logger.info("Starting hourly order update")
|
||||
# Get order management service
|
||||
order_management = self.service_manager.get_service('order_management')
|
||||
|
||||
# get all open orders from last 7 days
|
||||
orders = await order_management.get_order_ids(open_only=True, search_range="LastWeek")
|
||||
for order_id in orders:
|
||||
order = await order_management.get_order(order_id)
|
||||
with transaction(db):
|
||||
await order_management.save_order_to_db(order, db)
|
||||
|
||||
logger.info("Completed hourly order update")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating open orders: {str(e)}")
|
||||
raise
|
||||
|
||||
async def update_all_orders_daily(self):
|
||||
"""
|
||||
Daily update of all orders from TCGPlayer API to database
|
||||
"""
|
||||
db = get_db()
|
||||
try:
|
||||
logger.info("Starting daily order update")
|
||||
# Get order management service
|
||||
order_management = self.service_manager.get_service('order_management')
|
||||
|
||||
# get all order ids from last 3 months
|
||||
orders = await order_management.get_order_ids(open_only=False, search_range="LastThreeMonths")
|
||||
for order_id in orders:
|
||||
order = await order_management.get_order(order_id)
|
||||
with transaction(db):
|
||||
await order_management.save_order_to_db(order, db)
|
||||
|
||||
logger.info("Completed daily order update")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating all orders: {str(e)}")
|
||||
raise
|
||||
|
||||
async def start_scheduled_tasks(self):
|
||||
"""Start all scheduled tasks"""
|
||||
# Schedule TCGPlayer export processing to run daily at 2 AM
|
||||
@ -49,6 +101,27 @@ class SchedulerService:
|
||||
interval_seconds=24 * 60 * 60, # 24 hours
|
||||
export_type="live"
|
||||
)
|
||||
# Schedule open orders update to run hourly at 00 minutes
|
||||
await self.scheduler.schedule_task(
|
||||
task_name="update_open_orders_hourly",
|
||||
func=self.update_open_orders_hourly,
|
||||
interval_seconds=60 * 60, # 1 hour
|
||||
)
|
||||
# Schedule all orders update to run daily at 1 AM
|
||||
await self.scheduler.schedule_task(
|
||||
task_name="update_all_orders_daily",
|
||||
func=self.update_all_orders_daily,
|
||||
interval_seconds=24 * 60 * 60, # 24 hours
|
||||
)
|
||||
|
||||
self.scheduler.start()
|
||||
logger.info("All scheduled tasks started")
|
||||
logger.info("All scheduled tasks started")
|
||||
|
||||
async def close(self):
|
||||
"""Close all services used by the scheduler"""
|
||||
try:
|
||||
await self.scheduler.shutdown()
|
||||
logger.info("Scheduler services closed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing scheduler services: {str(e)}")
|
||||
raise
|
84
app/services/service_manager.py
Normal file
84
app/services/service_manager.py
Normal file
@ -0,0 +1,84 @@
|
||||
from typing import Dict, Any, Type
|
||||
import logging
|
||||
import importlib
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ServiceManager:
|
||||
_instance = None
|
||||
_initialized = False
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(ServiceManager, cls).__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if not self._initialized:
|
||||
self.services: Dict[str, Any] = {}
|
||||
self._service_classes = {
|
||||
'order_management': 'app.services.external_api.tcgplayer.order_management_service.OrderManagementService',
|
||||
'tcgplayer_inventory': 'app.services.external_api.tcgplayer.tcgplayer_inventory_service.TCGPlayerInventoryService',
|
||||
'label_printer': 'app.services.label_printer_service.LabelPrinterService',
|
||||
'regular_printer': 'app.services.regular_printer_service.RegularPrinterService',
|
||||
'address_label': 'app.services.address_label_service.AddressLabelService',
|
||||
'pull_sheet': 'app.services.pull_sheet_service.PullSheetService',
|
||||
'set_label': 'app.services.set_label_service.SetLabelService',
|
||||
'data_initialization': 'app.services.data_initialization.DataInitializationService',
|
||||
'scheduler': 'app.services.scheduler.scheduler_service.SchedulerService'
|
||||
}
|
||||
self._service_configs = {
|
||||
'label_printer': {'printer_api_url': "http://192.168.1.110:8000"},
|
||||
'regular_printer': {'printer_name': "MFCL2750DW-3"}
|
||||
}
|
||||
self._initialized = True
|
||||
|
||||
def _import_service(self, module_path: str) -> Type:
|
||||
"""Dynamically import a service class"""
|
||||
module_name, class_name = module_path.rsplit('.', 1)
|
||||
module = importlib.import_module(module_name)
|
||||
return getattr(module, class_name)
|
||||
|
||||
def register_service(self, service: Any) -> None:
|
||||
"""Register a service with the service manager"""
|
||||
service_name = service.__class__.__name__
|
||||
self.services[service_name] = service
|
||||
logger.info(f"Service {service_name} registered")
|
||||
|
||||
async def initialize_services(self):
|
||||
"""Initialize all services"""
|
||||
try:
|
||||
# Initialize services
|
||||
for name, class_path in self._service_classes.items():
|
||||
service_class = self._import_service(class_path)
|
||||
config = self._service_configs.get(name, {})
|
||||
self.services[name] = service_class(**config)
|
||||
self.register_service(self.services[name])
|
||||
|
||||
logger.info("All services initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize services: {str(e)}")
|
||||
raise
|
||||
|
||||
async def cleanup_services(self):
|
||||
"""Clean up all services"""
|
||||
try:
|
||||
# Clean up services in reverse order of initialization
|
||||
for name, service in reversed(self.services.items()):
|
||||
if hasattr(service, 'close'):
|
||||
await service.close()
|
||||
elif hasattr(service, 'cleanup'):
|
||||
await service.cleanup()
|
||||
logger.info(f"Service {name} cleaned up")
|
||||
|
||||
self.services.clear()
|
||||
logger.info("All services cleaned up successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clean up services: {str(e)}")
|
||||
raise
|
||||
|
||||
def get_service(self, name: str) -> Any:
|
||||
"""Get a service by name"""
|
||||
if name not in self.services:
|
||||
raise ValueError(f"Service {name} not found")
|
||||
return self.services[name]
|
@ -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()
|
262
app/services/set_label_service.py
Normal file
262
app/services/set_label_service.py
Normal file
@ -0,0 +1,262 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from sqlalchemy.orm import Session
|
||||
import aiohttp
|
||||
import jinja2
|
||||
from weasyprint import HTML
|
||||
from app.services.base_service import BaseService
|
||||
from app.models.tcgplayer_group import TCGPlayerGroup
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
ENV = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader("app/data/assets/templates"))
|
||||
|
||||
# Set types we are interested in
|
||||
SET_TYPES = (
|
||||
"core",
|
||||
"expansion",
|
||||
"starter", # Portal, P3k, welcome decks
|
||||
"masters",
|
||||
"commander",
|
||||
"planechase",
|
||||
"draft_innovation", # Battlebond, Conspiracy
|
||||
"duel_deck", # Duel Deck Elves,
|
||||
"premium_deck", # Premium Deck Series: Slivers, Premium Deck Series: Graveborn
|
||||
"from_the_vault", # Make sure to adjust the MINIMUM_SET_SIZE if you want these
|
||||
"archenemy",
|
||||
"box",
|
||||
"funny", # Unglued, Unhinged, Ponies: TG, etc.
|
||||
# "memorabilia", # Commander's Arsenal, Celebration Cards, World Champ Decks
|
||||
# "spellbook",
|
||||
# These are relatively large groups of sets
|
||||
# You almost certainly don't want these
|
||||
# "token",
|
||||
# "promo",
|
||||
)
|
||||
|
||||
# Only include sets at least this size
|
||||
# For reference, the smallest proper expansion is Arabian Nights with 78 cards
|
||||
MINIMUM_SET_SIZE = 50
|
||||
|
||||
# Set codes you might want to ignore
|
||||
IGNORED_SETS = (
|
||||
"cmb1", # Mystery Booster Playtest Cards
|
||||
"amh1", # Modern Horizon Art Series
|
||||
"cmb2", # Mystery Booster Playtest Cards Part Deux
|
||||
)
|
||||
|
||||
# Used to rename very long set names
|
||||
RENAME_SETS = {
|
||||
"Adventures in the Forgotten Realms": "Forgotten Realms",
|
||||
"Adventures in the Forgotten Realms Minigames": "Forgotten Realms Minigames",
|
||||
"Angels: They're Just Like Us but Cooler and with Wings": "Angels: Just Like Us",
|
||||
"Archenemy: Nicol Bolas Schemes": "Archenemy: Bolas Schemes",
|
||||
"Chronicles Foreign Black Border": "Chronicles FBB",
|
||||
"Commander Anthology Volume II": "Commander Anthology II",
|
||||
"Commander Legends: Battle for Baldur's Gate": "CMDR Legends: Baldur's Gate",
|
||||
"Dominaria United Commander": "Dominaria United [C]",
|
||||
"Duel Decks: Elves vs. Goblins": "DD: Elves vs. Goblins",
|
||||
"Duel Decks: Jace vs. Chandra": "DD: Jace vs. Chandra",
|
||||
"Duel Decks: Divine vs. Demonic": "DD: Divine vs. Demonic",
|
||||
"Duel Decks: Garruk vs. Liliana": "DD: Garruk vs. Liliana",
|
||||
"Duel Decks: Phyrexia vs. the Coalition": "DD: Phyrexia vs. Coalition",
|
||||
"Duel Decks: Elspeth vs. Tezzeret": "DD: Elspeth vs. Tezzeret",
|
||||
"Duel Decks: Knights vs. Dragons": "DD: Knights vs. Dragons",
|
||||
"Duel Decks: Ajani vs. Nicol Bolas": "DD: Ajani vs. Nicol Bolas",
|
||||
"Duel Decks: Heroes vs. Monsters": "DD: Heroes vs. Monsters",
|
||||
"Duel Decks: Speed vs. Cunning": "DD: Speed vs. Cunning",
|
||||
"Duel Decks Anthology: Elves vs. Goblins": "DDA: Elves vs. Goblins",
|
||||
"Duel Decks Anthology: Jace vs. Chandra": "DDA: Jace vs. Chandra",
|
||||
"Duel Decks Anthology: Divine vs. Demonic": "DDA: Divine vs. Demonic",
|
||||
"Duel Decks Anthology: Garruk vs. Liliana": "DDA: Garruk vs. Liliana",
|
||||
"Duel Decks: Elspeth vs. Kiora": "DD: Elspeth vs. Kiora",
|
||||
"Duel Decks: Zendikar vs. Eldrazi": "DD: Zendikar vs. Eldrazi",
|
||||
"Duel Decks: Blessed vs. Cursed": "DD: Blessed vs. Cursed",
|
||||
"Duel Decks: Nissa vs. Ob Nixilis": "DD: Nissa vs. Ob Nixilis",
|
||||
"Duel Decks: Merfolk vs. Goblins": "DD: Merfolk vs. Goblins",
|
||||
"Duel Decks: Elves vs. Inventors": "DD: Elves vs. Inventors",
|
||||
"Duel Decks: Mirrodin Pure vs. New Phyrexia": "DD: Mirrodin vs.New Phyrexia",
|
||||
"Duel Decks: Izzet vs. Golgari": "Duel Decks: Izzet vs. Golgari",
|
||||
"Fourth Edition Foreign Black Border": "Fourth Edition FBB",
|
||||
"Global Series Jiang Yanggu & Mu Yanling": "Jiang Yanggu & Mu Yanling",
|
||||
"Innistrad: Crimson Vow Minigames": "Crimson Vow Minigames",
|
||||
"Introductory Two-Player Set": "Intro Two-Player Set",
|
||||
"March of the Machine: The Aftermath": "MotM: The Aftermath",
|
||||
"March of the Machine Commander": "March of the Machine [C]",
|
||||
"Murders at Karlov Manor Commander": "Murders at Karlov Manor [C]",
|
||||
"Mystery Booster Playtest Cards": "Mystery Booster Playtest",
|
||||
"Mystery Booster Playtest Cards 2019": "MB Playtest Cards 2019",
|
||||
"Mystery Booster Playtest Cards 2021": "MB Playtest Cards 2021",
|
||||
"Mystery Booster Retail Edition Foils": "Mystery Booster Retail Foils",
|
||||
"Outlaws of Thunder Junction Commander": "Outlaws of Thunder Junction [C]",
|
||||
"Phyrexia: All Will Be One Commander": "Phyrexia: All Will Be One [C]",
|
||||
"Planechase Anthology Planes": "Planechase Anth. Planes",
|
||||
"Premium Deck Series: Slivers": "Premium Deck Slivers",
|
||||
"Premium Deck Series: Graveborn": "Premium Deck Graveborn",
|
||||
"Premium Deck Series: Fire and Lightning": "PD: Fire & Lightning",
|
||||
"Shadows over Innistrad Remastered": "SOI Remastered",
|
||||
"Strixhaven: School of Mages Minigames": "Strixhaven Minigames",
|
||||
"Tales of Middle-earth Commander": "Tales of Middle-earth [C]",
|
||||
"The Brothers' War Retro Artifacts": "Brothers' War Retro",
|
||||
"The Brothers' War Commander": "Brothers' War Commander",
|
||||
"The Lord of the Rings: Tales of Middle-earth": "LOTR: Tales of Middle-earth",
|
||||
"The Lost Caverns of Ixalan Commander": "The Lost Caverns of Ixalan [C]",
|
||||
"Warhammer 40,000 Commander": "Warhammer 40K [C]",
|
||||
"Wilds of Eldraine Commander": "Wilds of Eldraine [C]",
|
||||
"World Championship Decks 1997": "World Championship 1997",
|
||||
"World Championship Decks 1998": "World Championship 1998",
|
||||
"World Championship Decks 1999": "World Championship 1999",
|
||||
"World Championship Decks 2000": "World Championship 2000",
|
||||
"World Championship Decks 2001": "World Championship 2001",
|
||||
"World Championship Decks 2002": "World Championship 2002",
|
||||
"World Championship Decks 2003": "World Championship 2003",
|
||||
"World Championship Decks 2004": "World Championship 2004",
|
||||
}
|
||||
|
||||
|
||||
class SetLabelService(BaseService):
|
||||
DEFAULT_OUTPUT_DIR = "app/data/cache/set_labels"
|
||||
os.makedirs(DEFAULT_OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
def __init__(self, output_dir=DEFAULT_OUTPUT_DIR):
|
||||
super().__init__(None) # BaseService doesn't need a model for this service
|
||||
self.set_codes = []
|
||||
self.ignored_sets = IGNORED_SETS
|
||||
self.set_types = SET_TYPES
|
||||
self.minimum_set_size = MINIMUM_SET_SIZE
|
||||
self.output_dir = Path(output_dir)
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async def get_set_data(self, session):
|
||||
log.info("Getting set data and icons from Scryfall")
|
||||
|
||||
async with session.get("https://api.scryfall.com/sets") as resp:
|
||||
resp.raise_for_status()
|
||||
data = (await resp.json())["data"]
|
||||
set_data = []
|
||||
for exp in data:
|
||||
if exp["code"] in self.ignored_sets:
|
||||
continue
|
||||
elif exp["card_count"] < self.minimum_set_size:
|
||||
continue
|
||||
elif self.set_types and exp["set_type"] not in self.set_types:
|
||||
continue
|
||||
elif self.set_codes and exp["code"].lower() not in self.set_codes:
|
||||
continue
|
||||
else:
|
||||
set_data.append(exp)
|
||||
|
||||
if self.set_codes:
|
||||
known_sets = set([exp["code"] for exp in data])
|
||||
specified_sets = set([code.lower() for code in self.set_codes])
|
||||
unknown_sets = specified_sets.difference(known_sets)
|
||||
for set_code in unknown_sets:
|
||||
log.warning("Unknown set '%s'", set_code)
|
||||
|
||||
set_data.reverse()
|
||||
return set_data
|
||||
|
||||
async def get_set_icon(self, session, icon_url):
|
||||
try:
|
||||
async with session.get(icon_url) as resp:
|
||||
if resp.status == 200:
|
||||
icon_data = await resp.read()
|
||||
return base64.b64encode(icon_data).decode('utf-8')
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to fetch icon from {icon_url}: {e}")
|
||||
return None
|
||||
|
||||
async def generate_label(self, session, set_data):
|
||||
output_file = self.output_dir / f"{set_data['code']}.pdf"
|
||||
|
||||
# Check if file already exists
|
||||
if output_file.exists():
|
||||
log.info(f"Label already exists for {set_data['name']} ({set_data['code']})")
|
||||
return output_file
|
||||
|
||||
name = RENAME_SETS.get(set_data["name"], set_data["name"])
|
||||
icon_b64 = await self.get_set_icon(session, set_data["icon_svg_uri"])
|
||||
|
||||
template = ENV.get_template("set_label.html")
|
||||
html_content = template.render(
|
||||
name=name,
|
||||
code=set_data["code"],
|
||||
date=datetime.strptime(set_data["released_at"], "%Y-%m-%d").date(),
|
||||
icon_b64=icon_b64,
|
||||
)
|
||||
|
||||
HTML(string=html_content).write_pdf(output_file)
|
||||
log.info(f"Generated label for {name} ({set_data['code']})")
|
||||
return output_file
|
||||
|
||||
async def generate_labels(self, sets=None):
|
||||
if sets:
|
||||
self.ignored_sets = ()
|
||||
self.minimum_set_size = 0
|
||||
self.set_types = ()
|
||||
self.set_codes = [exp.lower() for exp in sets]
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
set_data = await self.get_set_data(session)
|
||||
tasks = [self.generate_label(session, exp) for exp in set_data]
|
||||
return await asyncio.gather(*tasks)
|
||||
|
||||
async def get_available_sets(self, db: Session):
|
||||
"""
|
||||
Get a list of available MTG sets that can be used for label generation.
|
||||
|
||||
Returns:
|
||||
List of set codes and their names
|
||||
"""
|
||||
try:
|
||||
# Get all sets from the database
|
||||
sets = db.query(TCGPlayerGroup).filter(
|
||||
TCGPlayerGroup.category_id == 1,
|
||||
TCGPlayerGroup.abbreviation.isnot(None),
|
||||
TCGPlayerGroup.abbreviation != ""
|
||||
).all()
|
||||
if not sets:
|
||||
log.warning("No sets found in database")
|
||||
return []
|
||||
return [{"code": set.abbreviation, "name": set.name} for set in sets]
|
||||
except Exception as e:
|
||||
log.error(f"Error getting available sets: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
def main():
|
||||
log_format = '[%(levelname)s] %(message)s'
|
||||
logging.basicConfig(format=log_format, level=logging.INFO)
|
||||
|
||||
parser = argparse.ArgumentParser(description="Generate MTG labels")
|
||||
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
default=SetLabelService.DEFAULT_OUTPUT_DIR,
|
||||
help="Output labels to this directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"sets",
|
||||
nargs="*",
|
||||
help=(
|
||||
"Only output sets with the specified set code (eg. MH1, NEO). "
|
||||
"This can be used multiple times."
|
||||
),
|
||||
metavar="SET",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
service = SetLabelService(args.output_dir)
|
||||
asyncio.run(service.generate_labels(args.sets))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
@ -139,12 +139,18 @@ class LabelPrinterReceiver:
|
||||
|
||||
# Use the send helper which handles the complete print lifecycle
|
||||
logger.info("Sending print data to printer...")
|
||||
result = send(
|
||||
instructions=print_data,
|
||||
printer_identifier=self.printer_identifier,
|
||||
backend_identifier='pyusb',
|
||||
blocking=True
|
||||
)
|
||||
try:
|
||||
result = send(
|
||||
instructions=print_data,
|
||||
printer_identifier=self.printer_identifier,
|
||||
backend_identifier='pyusb',
|
||||
blocking=True
|
||||
)
|
||||
except Exception as e:
|
||||
if "Device not found" in str(e):
|
||||
logger.error("Printer device not found")
|
||||
return {"message": "Printer device not found"}, 500
|
||||
raise
|
||||
|
||||
logger.debug(f"Print result: {result}")
|
||||
|
||||
|
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# This file makes the tests directory a Python package
|
95
tests/test_set_label_routes.py
Normal file
95
tests/test_set_label_routes.py
Normal file
@ -0,0 +1,95 @@
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import patch, MagicMock
|
||||
from app.main import app
|
||||
from app.services.set_label_service import SetLabelService
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import get_db
|
||||
import asyncio
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
def test_generate_set_labels_success():
|
||||
# Mock the SetLabelService
|
||||
with patch('app.routes.set_label_routes.set_label_service') as mock_service:
|
||||
# Setup mock
|
||||
mock_service.generate_labels.return_value = asyncio.Future()
|
||||
mock_service.generate_labels.return_value.set_result([
|
||||
"app/data/cache/set_labels/MH1.pdf",
|
||||
"app/data/cache/set_labels/NEO.pdf"
|
||||
])
|
||||
|
||||
# Make request
|
||||
response = client.post("/api/set-labels/generate", json={"sets": ["MH1", "NEO"]})
|
||||
|
||||
# Assert response
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"message": "Labels generated successfully",
|
||||
"files": [
|
||||
"app/data/cache/set_labels/MH1.pdf",
|
||||
"app/data/cache/set_labels/NEO.pdf"
|
||||
]
|
||||
}
|
||||
|
||||
# Verify service was called correctly
|
||||
mock_service.generate_labels.assert_called_once_with(["MH1", "NEO"])
|
||||
|
||||
def test_generate_set_labels_error():
|
||||
# Mock the SetLabelService to raise an exception
|
||||
with patch('app.routes.set_label_routes.set_label_service') as mock_service:
|
||||
mock_service.generate_labels.side_effect = Exception("Test error")
|
||||
|
||||
# Make request
|
||||
response = client.post("/api/set-labels/generate", json={"sets": ["MH1", "NEO"]})
|
||||
|
||||
# Assert response
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {"detail": "Test error"}
|
||||
|
||||
def test_get_available_sets_success():
|
||||
# Mock the database session and SetLabelService
|
||||
with patch('app.routes.set_label_routes.set_label_service') as mock_service, \
|
||||
patch('app.db.database.get_db') as mock_get_db:
|
||||
# Setup mock database session
|
||||
mock_db = MagicMock(spec=Session)
|
||||
mock_get_db.return_value = mock_db
|
||||
|
||||
# Setup mock service
|
||||
mock_sets = [
|
||||
{"code": "MH1", "name": "Modern Horizons"},
|
||||
{"code": "NEO", "name": "Kamigawa: Neon Dynasty"}
|
||||
]
|
||||
mock_service.get_available_sets.return_value = asyncio.Future()
|
||||
mock_service.get_available_sets.return_value.set_result(mock_sets)
|
||||
|
||||
# Override the dependency injection
|
||||
app.dependency_overrides[get_db] = lambda: mock_db
|
||||
|
||||
try:
|
||||
# Make request
|
||||
response = client.get("/api/set-labels/available-sets")
|
||||
|
||||
# Assert response
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"message": "Available sets retrieved successfully",
|
||||
"sets": mock_sets
|
||||
}
|
||||
|
||||
# Verify service was called correctly
|
||||
mock_service.get_available_sets.assert_called_once_with(mock_db)
|
||||
finally:
|
||||
# Clean up dependency override
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_get_available_sets_error():
|
||||
# Mock the SetLabelService to raise an exception
|
||||
with patch('app.routes.set_label_routes.set_label_service') as mock_service:
|
||||
mock_service.get_available_sets.side_effect = Exception("Test error")
|
||||
|
||||
# Make request
|
||||
response = client.get("/api/set-labels/available-sets")
|
||||
|
||||
# Assert response
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {"detail": "Test error"}
|
Loading…
x
Reference in New Issue
Block a user