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-16 23:58:58,575 - INFO - app.main - Application starting up...
|
||||||
2025-04-13 21:40:14,714 - INFO - app.main - Database initialized successfully
|
2025-04-16 23:58:58,622 - INFO - app.main - Database initialized successfully
|
||||||
2025-04-13 21:40:14,714 - INFO - app.main - TCGPlayer data initialized successfully
|
2025-04-16 23:58:58,622 - INFO - app.services.service_manager - Service OrderManagementService registered
|
||||||
2025-04-13 21:40:14,715 - INFO - app.main - Scheduler started successfully
|
2025-04-16 23:58:58,622 - INFO - app.services.service_manager - Service TCGPlayerInventoryService registered
|
||||||
2025-04-13 21:40:17,147 - WARNING - app.services.scheduler.base_scheduler - Event loop already closed, skipping scheduler shutdown
|
2025-04-16 23:58:58,622 - INFO - app.services.service_manager - Service LabelPrinterService registered
|
||||||
2025-04-13 21:40:17,147 - INFO - app.main - Scheduler shut down
|
2025-04-16 23:58:58,622 - INFO - app.services.service_manager - Service RegularPrinterService registered
|
||||||
2025-04-13 21:40:17,148 - INFO - app.main - Database connection closed
|
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%;
|
height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 2mm;
|
padding: 1mm;
|
||||||
page-break-after: always;
|
page-break-after: always;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main address centered */
|
/* Main address centered */
|
||||||
.address {
|
.address {
|
||||||
position: absolute;
|
display: flex;
|
||||||
top: 50%;
|
flex-direction: column;
|
||||||
left: 50%;
|
justify-content: center;
|
||||||
transform: translate(-50%, -50%);
|
align-items: center;
|
||||||
width: 85mm;
|
width: 88mm;
|
||||||
|
height: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipient-name {
|
.recipient-name {
|
||||||
font-size: 10pt;
|
font-size: 12pt;
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 1mm;
|
margin-bottom: 1mm;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.address-line {
|
.address-line {
|
||||||
font-size: 9pt;
|
font-size: 11pt;
|
||||||
line-height: 1.2;
|
line-height: 1.1;
|
||||||
margin-bottom: 0.5mm;
|
margin-bottom: 0.5mm;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.city-line {
|
.city-line {
|
||||||
font-size: 9pt;
|
font-size: 11pt;
|
||||||
font-weight: bold;
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@ -61,8 +62,7 @@ body {
|
|||||||
<div class="label-container">
|
<div class="label-container">
|
||||||
<div class="address">
|
<div class="address">
|
||||||
<div class="recipient-name">{{ recipient_name }}</div>
|
<div class="recipient-name">{{ recipient_name }}</div>
|
||||||
<div class="address-line">{{ address_line1 }}</div>
|
<div class="address-line">{{ address_line1 }}{% if address_line2 %} {{ address_line2 }}{% endif %}</div>
|
||||||
{% if address_line2 %}<div class="address-line">{{ address_line2 }}</div>{% endif %}
|
|
||||||
<div class="city-line">{{ city }}, {{ state }} {{ zip_code }}</div>
|
<div class="city-line">{{ city }}, {{ state }} {{ zip_code }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -80,15 +80,16 @@ tr:hover {
|
|||||||
width: 150px;
|
width: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.condition {
|
|
||||||
width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rarity {
|
.rarity {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-number {
|
||||||
|
width: 50px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.product-name {
|
.product-name {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
@ -114,20 +115,20 @@ tbody tr:hover {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="product-name">Product Name</th>
|
<th class="product-name">Product Name</th>
|
||||||
<th class="condition">Condition</th>
|
|
||||||
<th class="quantity">Qty</th>
|
<th class="quantity">Qty</th>
|
||||||
<th class="set">Set</th>
|
<th class="set">Set</th>
|
||||||
<th class="rarity">Rarity</th>
|
<th class="rarity">Rarity</th>
|
||||||
|
<th class="card-number">Card #</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<tr class="{{ 'foil' if 'Foil' in item.condition else '' }} {{ 'multiple' if item.quantity|int > 1 else '' }}">
|
<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="product-name">{{ item.product_name }}</td>
|
||||||
<td class="condition">{{ item.condition }}</td>
|
|
||||||
<td class="quantity">{{ item.quantity }}</td>
|
<td class="quantity">{{ item.quantity }}</td>
|
||||||
<td class="set">{{ item.set }}</td>
|
<td class="set">{{ item.set }}</td>
|
||||||
<td class="rarity">{{ item.rarity }}</td>
|
<td class="rarity">{{ item.rarity }}</td>
|
||||||
|
<td class="card-number">{{ item.card_number }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</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
|
import os
|
||||||
from app.routes import routes
|
from app.routes import routes
|
||||||
from app.db.database import init_db, SessionLocal
|
from app.db.database import init_db, SessionLocal
|
||||||
from app.services.scheduler.scheduler_service import SchedulerService
|
from app.services.service_manager import ServiceManager
|
||||||
from app.services.data_initialization import DataInitializationService
|
import logging
|
||||||
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
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
log_file = "app.log"
|
log_file = "app.log"
|
||||||
if os.path.exists(log_file):
|
if os.path.exists(log_file):
|
||||||
@ -39,76 +34,28 @@ root_logger.addHandler(file_handler)
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.info("Application starting up...")
|
logger.info("Application starting up...")
|
||||||
|
|
||||||
# Initialize scheduler service
|
# Initialize service manager
|
||||||
scheduler_service = SchedulerService()
|
service_manager = ServiceManager()
|
||||||
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")
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup
|
# Startup
|
||||||
init_db()
|
init_db()
|
||||||
logger.info("Database initialized successfully")
|
logger.info("Database initialized successfully")
|
||||||
|
|
||||||
# Initialize TCGPlayer data
|
# Initialize all services
|
||||||
db = SessionLocal()
|
await service_manager.initialize_services()
|
||||||
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()
|
|
||||||
|
|
||||||
# Start the scheduler
|
# Start the scheduler
|
||||||
#await scheduler_service.start_scheduled_tasks()
|
scheduler = service_manager.get_service('scheduler')
|
||||||
#await scheduler_service.process_tcgplayer_export(export_type="live", use_cache=True)
|
await scheduler.start_scheduled_tasks()
|
||||||
logger.info("Scheduler started successfully")
|
logger.info("Scheduler started successfully")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
await scheduler_service.scheduler.shutdown()
|
await service_manager.cleanup_services()
|
||||||
await data_init_service.close()
|
logger.info("All services cleaned up successfully")
|
||||||
logger.info("Scheduler shut down")
|
|
||||||
logger.info("Database connection closed")
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="CCR Cards Management API",
|
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.models.game import Game as GameModel
|
||||||
from app.schemas.game import GameCreate, GameUpdate, GameDelete, GameList, GameInDB
|
from app.schemas.game import GameCreate, GameUpdate, GameDelete, GameList, GameInDB
|
||||||
from app.models.card import Card as CardModel
|
from app.models.card import Card as CardModel
|
||||||
from app.schemas.card import CardCreate, CardUpdate, CardDelete, CardList, CardInDB
|
from app.routes.set_label_routes import router as set_label_router
|
||||||
from app.services import CardService, OrderService
|
from app.routes.order_routes import router as order_router
|
||||||
from app.services.file_processing_service import FileProcessingService
|
|
||||||
from app.services.external_api.tcgplayer.tcgplayer_inventory_service import TCGPlayerInventoryService
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
# Initialize services
|
# Include set label routes
|
||||||
card_service = CardService()
|
router.include_router(set_label_router)
|
||||||
order_service = OrderService()
|
|
||||||
file_processing_service = FileProcessingService()
|
# Include order routes
|
||||||
tcgplayer_inventory_service = TCGPlayerInventoryService()
|
router.include_router(order_router)
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Health Check & Root Endpoints
|
# Health Check & Root Endpoints
|
||||||
@ -32,80 +30,6 @@ async def root():
|
|||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok"}
|
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
|
# 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.base_service import BaseService
|
||||||
from app.services.card_service import CardService
|
from app.services.service_manager import ServiceManager
|
||||||
from app.services.order_service import OrderService
|
|
||||||
from app.services.file_processing_service import FileProcessingService
|
from app.services.file_processing_service import FileProcessingService
|
||||||
from app.services.inventory_service import InventoryService
|
from app.services.inventory_service import InventoryService
|
||||||
from app.services.service_registry import ServiceRegistry
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'BaseService',
|
'BaseService',
|
||||||
'CardService',
|
'ServiceManager',
|
||||||
'OrderService',
|
|
||||||
'FileProcessingService',
|
'FileProcessingService',
|
||||||
'InventoryService',
|
'InventoryService'
|
||||||
'ServiceRegistry'
|
|
||||||
]
|
]
|
@ -4,6 +4,11 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
from weasyprint import HTML
|
from weasyprint import HTML
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class AddressLabelService:
|
class AddressLabelService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@ -16,8 +21,9 @@ class AddressLabelService:
|
|||||||
self.return_address_path = "file://" + os.path.abspath("app/data/assets/images/ccrcardsaddress.png")
|
self.return_address_path = "file://" + os.path.abspath("app/data/assets/images/ccrcardsaddress.png")
|
||||||
self.output_dir = "app/data/cache/tcgplayer/address_labels/"
|
self.output_dir = "app/data/cache/tcgplayer/address_labels/"
|
||||||
os.makedirs(self.output_dir, exist_ok=True)
|
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.
|
"""Generate address labels from a CSV file and save them as PDFs.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -29,17 +35,30 @@ class AddressLabelService:
|
|||||||
"""
|
"""
|
||||||
generated_files = []
|
generated_files = []
|
||||||
|
|
||||||
with open(csv_path, 'r') as csvfile:
|
# Read CSV file in a thread pool
|
||||||
reader = csv.DictReader(csvfile)
|
loop = asyncio.get_event_loop()
|
||||||
for row in reader:
|
rows = await loop.run_in_executor(self.executor, self._read_csv, csv_path)
|
||||||
# Generate label for each row
|
|
||||||
pdf_path = self._generate_single_label(row, label_type)
|
for row in rows:
|
||||||
if pdf_path:
|
# if value of Value Of Products is greater than 50, skip
|
||||||
generated_files.append(str(pdf_path))
|
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
|
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.
|
"""Generate a single address label PDF.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -67,11 +86,15 @@ class AddressLabelService:
|
|||||||
# Render HTML
|
# Render HTML
|
||||||
html_content = self.templates[label_type].render(**template_data)
|
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"
|
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
|
return pdf_path
|
||||||
|
|
||||||
except Exception as e:
|
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
|
return None
|
@ -1,15 +1,12 @@
|
|||||||
from typing import Type, TypeVar, Generic, List, Optional, Any
|
from typing import Type, TypeVar, Generic, List, Optional, Any
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.db.database import Base
|
from app.db.database import Base
|
||||||
from app.services.service_registry import ServiceRegistry
|
|
||||||
|
|
||||||
T = TypeVar('T')
|
T = TypeVar('T')
|
||||||
|
|
||||||
class BaseService(Generic[T]):
|
class BaseService(Generic[T]):
|
||||||
def __init__(self, model: Type[T]):
|
def __init__(self, model: Type[T]):
|
||||||
self.model = model
|
self.model = model
|
||||||
# Register the service instance
|
|
||||||
ServiceRegistry.register(self.__class__.__name__, self)
|
|
||||||
|
|
||||||
def get(self, db: Session, id: int) -> Optional[T]:
|
def get(self, db: Session, id: int) -> Optional[T]:
|
||||||
return db.query(self.model).filter(self.model.id == id).first()
|
return db.query(self.model).filter(self.model.id == id).first()
|
||||||
@ -39,7 +36,4 @@ class BaseService(Generic[T]):
|
|||||||
db.delete(obj)
|
db.delete(obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_service(self, service_name: str) -> Any:
|
|
||||||
return ServiceRegistry.get(service_name)
|
|
@ -1,7 +1,6 @@
|
|||||||
from typing import Any, Dict, Optional, Union
|
from typing import Any, Dict, Optional, Union
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import logging
|
import logging
|
||||||
from app.services.service_registry import ServiceRegistry
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -11,8 +10,6 @@ class BaseExternalService:
|
|||||||
self.base_url = base_url
|
self.base_url = base_url
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.session = None
|
self.session = None
|
||||||
# Register the service instance
|
|
||||||
ServiceRegistry.register(self.__class__.__name__, self)
|
|
||||||
|
|
||||||
async def _get_session(self) -> aiohttp.ClientSession:
|
async def _get_session(self) -> aiohttp.ClientSession:
|
||||||
if self.session is None or self.session.closed:
|
if self.session is None or self.session.closed:
|
||||||
@ -72,5 +69,8 @@ class BaseExternalService:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
|
"""Close the aiohttp session if it exists"""
|
||||||
if self.session and not self.session.closed:
|
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 datetime import datetime
|
||||||
from app.models.mtgjson_card import MTGJSONCard
|
from app.models.mtgjson_card import MTGJSONCard
|
||||||
from app.models.mtgjson_sku import MTGJSONSKU
|
from app.models.mtgjson_sku import MTGJSONSKU
|
||||||
|
from app.db.database import get_db, transaction
|
||||||
|
|
||||||
class MTGJSONService:
|
class MTGJSONService:
|
||||||
def __init__(self, cache_dir: str = "app/data/cache/mtgjson", batch_size: int = 1000):
|
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():
|
for key, value in data["data"].items():
|
||||||
yield {"type": "item", "data": {key: value}}
|
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"""
|
"""Process a batch of items and add them to the database"""
|
||||||
processed = 0
|
processed = 0
|
||||||
for item in items:
|
with transaction(db):
|
||||||
if model_class == MTGJSONCard:
|
for item in items:
|
||||||
# Check if card already exists
|
if model_class == MTGJSONCard:
|
||||||
existing_card = db.query(MTGJSONCard).filter(MTGJSONCard.card_id == item["card_id"]).first()
|
# Check if card already exists
|
||||||
if existing_card:
|
existing_card = db.query(MTGJSONCard).filter(MTGJSONCard.card_id == item["card_id"]).first()
|
||||||
continue
|
if existing_card:
|
||||||
|
continue
|
||||||
new_item = MTGJSONCard(
|
|
||||||
card_id=item["card_id"],
|
new_item = MTGJSONCard(
|
||||||
name=item["name"],
|
card_id=item["card_id"],
|
||||||
set_code=item["set_code"],
|
name=item["name"],
|
||||||
uuid=item["uuid"],
|
set_code=item["set_code"],
|
||||||
abu_id=item.get("abu_id"),
|
uuid=item["uuid"],
|
||||||
card_kingdom_etched_id=item.get("card_kingdom_etched_id"),
|
abu_id=item.get("abu_id"),
|
||||||
card_kingdom_foil_id=item.get("card_kingdom_foil_id"),
|
card_kingdom_etched_id=item.get("card_kingdom_etched_id"),
|
||||||
card_kingdom_id=item.get("card_kingdom_id"),
|
card_kingdom_foil_id=item.get("card_kingdom_foil_id"),
|
||||||
cardsphere_id=item.get("cardsphere_id"),
|
card_kingdom_id=item.get("card_kingdom_id"),
|
||||||
cardsphere_foil_id=item.get("cardsphere_foil_id"),
|
cardsphere_id=item.get("cardsphere_id"),
|
||||||
cardtrader_id=item.get("cardtrader_id"),
|
cardsphere_foil_id=item.get("cardsphere_foil_id"),
|
||||||
csi_id=item.get("csi_id"),
|
cardtrader_id=item.get("cardtrader_id"),
|
||||||
mcm_id=item.get("mcm_id"),
|
csi_id=item.get("csi_id"),
|
||||||
mcm_meta_id=item.get("mcm_meta_id"),
|
mcm_id=item.get("mcm_id"),
|
||||||
miniaturemarket_id=item.get("miniaturemarket_id"),
|
mcm_meta_id=item.get("mcm_meta_id"),
|
||||||
mtg_arena_id=item.get("mtg_arena_id"),
|
miniaturemarket_id=item.get("miniaturemarket_id"),
|
||||||
mtgjson_foil_version_id=item.get("mtgjson_foil_version_id"),
|
mtg_arena_id=item.get("mtg_arena_id"),
|
||||||
mtgjson_non_foil_version_id=item.get("mtgjson_non_foil_version_id"),
|
mtgjson_foil_version_id=item.get("mtgjson_foil_version_id"),
|
||||||
mtgjson_v4_id=item.get("mtgjson_v4_id"),
|
mtgjson_non_foil_version_id=item.get("mtgjson_non_foil_version_id"),
|
||||||
mtgo_foil_id=item.get("mtgo_foil_id"),
|
mtgjson_v4_id=item.get("mtgjson_v4_id"),
|
||||||
mtgo_id=item.get("mtgo_id"),
|
mtgo_foil_id=item.get("mtgo_foil_id"),
|
||||||
multiverse_id=item.get("multiverse_id"),
|
mtgo_id=item.get("mtgo_id"),
|
||||||
scg_id=item.get("scg_id"),
|
multiverse_id=item.get("multiverse_id"),
|
||||||
scryfall_id=item.get("scryfall_id"),
|
scg_id=item.get("scg_id"),
|
||||||
scryfall_card_back_id=item.get("scryfall_card_back_id"),
|
scryfall_id=item.get("scryfall_id"),
|
||||||
scryfall_oracle_id=item.get("scryfall_oracle_id"),
|
scryfall_card_back_id=item.get("scryfall_card_back_id"),
|
||||||
scryfall_illustration_id=item.get("scryfall_illustration_id"),
|
scryfall_oracle_id=item.get("scryfall_oracle_id"),
|
||||||
tcgplayer_product_id=item.get("tcgplayer_product_id"),
|
scryfall_illustration_id=item.get("scryfall_illustration_id"),
|
||||||
tcgplayer_etched_product_id=item.get("tcgplayer_etched_product_id"),
|
tcgplayer_product_id=item.get("tcgplayer_product_id"),
|
||||||
tnt_id=item.get("tnt_id")
|
tcgplayer_etched_product_id=item.get("tcgplayer_etched_product_id"),
|
||||||
)
|
tnt_id=item.get("tnt_id")
|
||||||
else: # MTGJSONSKU
|
)
|
||||||
# Check if SKU already exists
|
else: # MTGJSONSKU
|
||||||
existing_sku = db.query(MTGJSONSKU).filter(MTGJSONSKU.sku_id == item["sku_id"]).first()
|
# Check if SKU already exists
|
||||||
if existing_sku:
|
existing_sku = db.query(MTGJSONSKU).filter(MTGJSONSKU.sku_id == item["sku_id"]).first()
|
||||||
continue
|
if existing_sku:
|
||||||
|
continue
|
||||||
new_item = MTGJSONSKU(
|
|
||||||
sku_id=str(item["sku_id"]),
|
new_item = MTGJSONSKU(
|
||||||
product_id=str(item["product_id"]),
|
sku_id=str(item["sku_id"]),
|
||||||
condition=item["condition"],
|
product_id=str(item["product_id"]),
|
||||||
finish=item["finish"],
|
condition=item["condition"],
|
||||||
language=item["language"],
|
finish=item["finish"],
|
||||||
printing=item["printing"],
|
language=item["language"],
|
||||||
card_id=item["card_id"]
|
printing=item["printing"],
|
||||||
)
|
card_id=item["card_id"]
|
||||||
db.add(new_item)
|
)
|
||||||
processed += 1
|
db.add(new_item)
|
||||||
|
processed += 1
|
||||||
if commit:
|
|
||||||
try:
|
|
||||||
db.commit()
|
|
||||||
except Exception as e:
|
|
||||||
db.rollback()
|
|
||||||
raise e
|
|
||||||
return processed
|
return processed
|
||||||
|
|
||||||
async def download_and_process_identifiers(self, db: Session) -> Dict[str, int]:
|
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_group import TCGPlayerGroup
|
||||||
from app.models.tcgplayer_product import TCGPlayerProduct
|
from app.models.tcgplayer_product import TCGPlayerProduct
|
||||||
from app.models.tcgplayer_category import TCGPlayerCategory
|
from app.models.tcgplayer_category import TCGPlayerCategory
|
||||||
|
from app.db.database import get_db, transaction
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
import py7zr
|
import py7zr
|
||||||
import os
|
import os
|
||||||
@ -100,42 +101,41 @@ class TCGCSVService(BaseExternalService):
|
|||||||
|
|
||||||
groups = response.get("results", [])
|
groups = response.get("results", [])
|
||||||
synced_groups = []
|
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:
|
# Check if group already exists
|
||||||
# Convert string dates to datetime objects
|
existing_group = db.query(TCGPlayerGroup).filter(TCGPlayerGroup.group_id == group_data["groupId"]).first()
|
||||||
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
|
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
|
return synced_groups
|
||||||
|
|
||||||
async def sync_products_to_db(self, db: Session, game_id: int, group_id: int) -> List[TCGPlayerProduct]:
|
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)
|
synced_products.append(existing_product)
|
||||||
else:
|
else:
|
||||||
# Create new product
|
# Create new product
|
||||||
new_product = TCGPlayerProduct(
|
with transaction(db):
|
||||||
product_id=int(product_data["productId"]),
|
new_product = TCGPlayerProduct(
|
||||||
name=product_data["name"],
|
product_id=int(product_data["productId"]),
|
||||||
clean_name=product_data.get("cleanName"),
|
name=product_data["name"],
|
||||||
image_url=product_data.get("imageUrl"),
|
clean_name=product_data.get("cleanName"),
|
||||||
category_id=int(product_data["categoryId"]),
|
image_url=product_data.get("imageUrl"),
|
||||||
group_id=int(product_data["groupId"]),
|
category_id=int(product_data["categoryId"]),
|
||||||
url=product_data.get("url"),
|
group_id=int(product_data["groupId"]),
|
||||||
modified_on=modified_on,
|
url=product_data.get("url"),
|
||||||
image_count=int(product_data.get("imageCount", 0)),
|
modified_on=modified_on,
|
||||||
ext_rarity=product_data.get("extRarity"),
|
image_count=int(product_data.get("imageCount", 0)),
|
||||||
ext_number=product_data.get("extNumber"),
|
ext_rarity=product_data.get("extRarity"),
|
||||||
low_price=parse_price(product_data.get("lowPrice")),
|
ext_number=product_data.get("extNumber"),
|
||||||
mid_price=parse_price(product_data.get("midPrice")),
|
low_price=parse_price(product_data.get("lowPrice")),
|
||||||
high_price=parse_price(product_data.get("highPrice")),
|
mid_price=parse_price(product_data.get("midPrice")),
|
||||||
market_price=parse_price(product_data.get("marketPrice")),
|
high_price=parse_price(product_data.get("highPrice")),
|
||||||
direct_low_price=parse_price(product_data.get("directLowPrice")),
|
market_price=parse_price(product_data.get("marketPrice")),
|
||||||
sub_type_name=product_data.get("subTypeName")
|
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.add(new_product)
|
||||||
|
synced_products.append(new_product)
|
||||||
|
|
||||||
db.commit()
|
|
||||||
return synced_products
|
return synced_products
|
||||||
|
|
||||||
async def sync_categories_to_db(self, db: Session) -> List[TCGPlayerCategory]:
|
async def sync_categories_to_db(self, db: Session) -> List[TCGPlayerCategory]:
|
||||||
@ -212,51 +212,50 @@ class TCGCSVService(BaseExternalService):
|
|||||||
|
|
||||||
categories = response.get("results", [])
|
categories = response.get("results", [])
|
||||||
synced_categories = []
|
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:
|
# Check if category already exists
|
||||||
# Convert string dates to datetime objects
|
existing_category = db.query(TCGPlayerCategory).filter(TCGPlayerCategory.category_id == category_data["categoryId"]).first()
|
||||||
modified_on = datetime.fromisoformat(category_data["modifiedOn"].replace("Z", "+00:00")) if category_data.get("modifiedOn") else None
|
|
||||||
|
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
|
return synced_categories
|
||||||
|
@ -67,9 +67,6 @@ class BaseTCGPlayerService(BaseExternalService):
|
|||||||
auth_required: bool = True,
|
auth_required: bool = True,
|
||||||
download_file: bool = False
|
download_file: bool = False
|
||||||
) -> Union[Dict[str, Any], bytes]:
|
) -> Union[Dict[str, Any], bytes]:
|
||||||
session = await self._get_session()
|
|
||||||
url = f"{self.base_url}{endpoint}"
|
|
||||||
|
|
||||||
# Get the authentication cookie if required
|
# Get the authentication cookie if required
|
||||||
if auth_required:
|
if auth_required:
|
||||||
cookie = self.credentials.get_cookie()
|
cookie = self.credentials.get_cookie()
|
||||||
@ -83,28 +80,26 @@ class BaseTCGPlayerService(BaseExternalService):
|
|||||||
request_headers["Cookie"] = cookie
|
request_headers["Cookie"] = cookie
|
||||||
else:
|
else:
|
||||||
request_headers = headers or {}
|
request_headers = headers or {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with session.request(method, url, params=params, headers=request_headers, json=data) as response:
|
# Use the parent class's _make_request with our custom headers and binary flag
|
||||||
if response.status == 401:
|
response = await super()._make_request(
|
||||||
raise RuntimeError("TCGPlayer authentication failed. Cookie may be invalid or expired.")
|
method=method,
|
||||||
response.raise_for_status()
|
endpoint=endpoint,
|
||||||
|
params=params,
|
||||||
if download_file:
|
headers=request_headers,
|
||||||
return await response.read()
|
data=data,
|
||||||
return await response.json()
|
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:
|
except aiohttp.ClientError as e:
|
||||||
logger.error(f"TCGPlayer API request failed: {str(e)}")
|
logger.error(f"TCGPlayer API request failed: {str(e)}")
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error during TCGPlayer API request: {str(e)}")
|
logger.error(f"Unexpected error during TCGPlayer API request: {str(e)}")
|
||||||
raise
|
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()
|
|
@ -1,7 +1,26 @@
|
|||||||
from typing import Any, Dict, Optional, Union
|
from typing import Any, Dict, Optional, Union
|
||||||
import logging
|
import logging
|
||||||
from app.services.external_api.tcgplayer.base_tcgplayer_service import BaseTCGPlayerService
|
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 os
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -20,12 +39,20 @@ class OrderManagementService(BaseTCGPlayerService):
|
|||||||
self.shipping_endpoint = f"/shipping/export{self.API_VERSION}"
|
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
|
search_from = 0
|
||||||
orders = []
|
orders = []
|
||||||
while True:
|
while True:
|
||||||
payload = {
|
payload = {
|
||||||
"searchRange": "LastThreeMonths",
|
"searchRange": search_range,
|
||||||
"filters": {
|
"filters": {
|
||||||
"sellerKey": self.SELLER_KEY
|
"sellerKey": self.SELLER_KEY
|
||||||
},
|
},
|
||||||
@ -37,17 +64,27 @@ class OrderManagementService(BaseTCGPlayerService):
|
|||||||
"size": 25
|
"size": 25
|
||||||
}
|
}
|
||||||
if open_only:
|
if open_only:
|
||||||
payload["filters"]["orderStatus"] = ["Processing","ReadyToShip","Received","Pulling","ReadyForPickup"]
|
payload["filters"]["orderStatuses"] = ["Processing","ReadyToShip","Received","Pulling","ReadyForPickup"]
|
||||||
payload["filters"]["fulfillmentTypes"] = ["Normal"]
|
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)
|
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:
|
if len(response.get("orders")) == 0:
|
||||||
break
|
break
|
||||||
search_from += 25
|
search_from += 25
|
||||||
orders.extend(response.get("orders"))
|
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
|
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):
|
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
|
return response
|
||||||
|
|
||||||
async def get_packing_slip(self, order_ids: list[str]):
|
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)
|
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
|
return response
|
||||||
|
|
||||||
async def get_pull_sheet(self, order_ids: list[str]):
|
async def get_pull_sheet(self, order_ids: list[str]):
|
||||||
payload = {
|
payload = {
|
||||||
"orderNumbers": order_ids,
|
"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)
|
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
|
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"):
|
if not os.path.exists("app/data/cache/tcgplayer/orders"):
|
||||||
os.makedirs("app/data/cache/tcgplayer/orders")
|
os.makedirs("app/data/cache/tcgplayer/orders")
|
||||||
file_path = f"app/data/cache/tcgplayer/orders/{file_name}"
|
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:
|
with open(file_path, "wb") as f:
|
||||||
f.write(file_data)
|
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._session = None
|
||||||
self._lock = asyncio.Lock()
|
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
|
@asynccontextmanager
|
||||||
async def _get_session(self):
|
async def _get_session(self):
|
||||||
"""Context manager for aiohttp session."""
|
"""Context manager for aiohttp session."""
|
||||||
@ -64,8 +71,16 @@ class LabelPrinterService:
|
|||||||
elif response.status == 404:
|
elif response.status == 404:
|
||||||
logger.error(f"Printer status endpoint not found at {self.status_url}")
|
logger.error(f"Printer status endpoint not found at {self.status_url}")
|
||||||
return False
|
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:
|
except aiohttp.ClientError as e:
|
||||||
logger.warning(f"Error checking printer status: {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:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error in _wait_for_printer_ready: {e}")
|
logger.error(f"Unexpected error in _wait_for_printer_ready: {e}")
|
||||||
return False
|
return False
|
||||||
@ -109,6 +124,11 @@ class LabelPrinterService:
|
|||||||
elif response.status == 429:
|
elif response.status == 429:
|
||||||
logger.error("Printer is busy")
|
logger.error("Printer is busy")
|
||||||
return False
|
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:
|
else:
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
logger.error(f"Print request failed with status {response.status}: {data.get('message')}")
|
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}")
|
logger.error(f"Unexpected error in _send_print_request: {e}")
|
||||||
return False
|
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.
|
"""Print a PDF or PNG file to the label printer.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: Path to the PDF or PNG file
|
file_path: Path to the PDF or PNG file
|
||||||
label_size: Size of label to use ("dk1201" or "dk1241")
|
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:
|
Returns:
|
||||||
bool: True if print was successful, False otherwise
|
bool: True if print was successful, False otherwise
|
||||||
@ -177,14 +197,16 @@ class LabelPrinterService:
|
|||||||
resized_image = resized_image.resize((1660, 1164), Image.Resampling.LANCZOS)
|
resized_image = resized_image.resize((1660, 1164), Image.Resampling.LANCZOS)
|
||||||
else:
|
else:
|
||||||
resized_image = resized_image.resize((1164, 1660), Image.Resampling.LANCZOS)
|
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)
|
resized_image = resized_image.resize((991, 306), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
# if file path contains address_label, rotate image 90 degrees
|
# 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"
|
rotate = "90"
|
||||||
|
cut = False
|
||||||
else:
|
else:
|
||||||
rotate = "0"
|
rotate = "0"
|
||||||
|
cut = True
|
||||||
|
|
||||||
# Convert to label format
|
# Convert to label format
|
||||||
qlr = BrotherQLRaster("QL-1100")
|
qlr = BrotherQLRaster("QL-1100")
|
||||||
@ -205,7 +227,7 @@ class LabelPrinterService:
|
|||||||
dpi_600=False,
|
dpi_600=False,
|
||||||
#hq=True,
|
#hq=True,
|
||||||
hq=False,
|
hq=False,
|
||||||
cut=True
|
cut=cut
|
||||||
)
|
)
|
||||||
|
|
||||||
# Cache the converted binary data
|
# 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 jinja2 import Environment, FileSystemLoader
|
||||||
from weasyprint import HTML
|
from weasyprint import HTML
|
||||||
import logging
|
import logging
|
||||||
|
import asyncio
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -16,7 +17,7 @@ class PullSheetService:
|
|||||||
self.output_dir = Path("app/data/cache/tcgplayer/pull_sheets")
|
self.output_dir = Path("app/data/cache/tcgplayer/pull_sheets")
|
||||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
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.
|
"""Generate a PDF pull sheet from a CSV file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -27,7 +28,7 @@ class PullSheetService:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Read and process CSV data
|
# 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
|
# Prepare template data
|
||||||
template_data = {
|
template_data = {
|
||||||
@ -38,9 +39,12 @@ class PullSheetService:
|
|||||||
# Render HTML
|
# Render HTML
|
||||||
html_content = self.template.render(**template_data)
|
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"
|
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)
|
return str(pdf_path)
|
||||||
|
|
||||||
@ -48,7 +52,7 @@ class PullSheetService:
|
|||||||
logger.error(f"Error generating pull sheet PDF: {str(e)}")
|
logger.error(f"Error generating pull sheet PDF: {str(e)}")
|
||||||
raise
|
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.
|
"""Read and process CSV data using pandas.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -57,8 +61,11 @@ class PullSheetService:
|
|||||||
Returns:
|
Returns:
|
||||||
List of processed items
|
List of processed items
|
||||||
"""
|
"""
|
||||||
# Read CSV into pandas DataFrame
|
# Read CSV into pandas DataFrame in a separate thread to avoid blocking
|
||||||
df = pd.read_csv(csv_path)
|
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
|
# Filter out the "Orders Contained in Pull Sheet" row
|
||||||
df = df[df['Product Line'] != 'Orders Contained in Pull Sheet:']
|
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')
|
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)
|
# 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
|
# Convert to list of dictionaries
|
||||||
items = []
|
items = []
|
||||||
@ -77,7 +84,8 @@ class PullSheetService:
|
|||||||
'condition': row['Condition'],
|
'condition': row['Condition'],
|
||||||
'quantity': str(int(row['Quantity'])), # Convert to string for template
|
'quantity': str(int(row['Quantity'])), # Convert to string for template
|
||||||
'set': row['Set'],
|
'set': row['Set'],
|
||||||
'rarity': row['Rarity']
|
'rarity': row['Rarity'],
|
||||||
|
'card_number': str(int(row['Number'])) if 'Number' in row else ''
|
||||||
})
|
})
|
||||||
|
|
||||||
return items
|
return items
|
@ -2,7 +2,7 @@ from typing import Callable, Dict, Any
|
|||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from apscheduler.triggers.interval import IntervalTrigger
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
import logging
|
import logging
|
||||||
from app.services.service_registry import ServiceRegistry
|
from apscheduler.schedulers.base import SchedulerNotRunningError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -10,7 +10,7 @@ class BaseScheduler:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.scheduler = AsyncIOScheduler()
|
self.scheduler = AsyncIOScheduler()
|
||||||
self.jobs: Dict[str, Any] = {}
|
self.jobs: Dict[str, Any] = {}
|
||||||
ServiceRegistry.register(self.__class__.__name__, self)
|
self._is_running = False
|
||||||
|
|
||||||
async def schedule_task(
|
async def schedule_task(
|
||||||
self,
|
self,
|
||||||
@ -46,16 +46,20 @@ class BaseScheduler:
|
|||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
"""Start the scheduler"""
|
"""Start the scheduler"""
|
||||||
self.scheduler.start()
|
if not self._is_running:
|
||||||
logger.info("Scheduler started")
|
self.scheduler.start()
|
||||||
|
self._is_running = True
|
||||||
|
logger.info("Scheduler started")
|
||||||
|
|
||||||
async def shutdown(self) -> None:
|
async def shutdown(self) -> None:
|
||||||
"""Shutdown the scheduler"""
|
"""Shutdown the scheduler"""
|
||||||
try:
|
if self._is_running:
|
||||||
self.scheduler.shutdown()
|
try:
|
||||||
logger.info("Scheduler stopped")
|
self.scheduler.shutdown()
|
||||||
except AttributeError as e:
|
self._is_running = False
|
||||||
if "'NoneType' object has no attribute 'call_soon_threadsafe'" in str(e):
|
logger.info("Scheduler stopped")
|
||||||
logger.warning("Event loop already closed, skipping scheduler shutdown")
|
except SchedulerNotRunningError:
|
||||||
else:
|
logger.warning("Scheduler was already stopped")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error shutting down scheduler: {str(e)}")
|
||||||
raise
|
raise
|
@ -1,7 +1,4 @@
|
|||||||
from sqlalchemy.orm import Session
|
from app.db.database import get_db, transaction
|
||||||
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.services.scheduler.base_scheduler import BaseScheduler
|
from app.services.scheduler.base_scheduler import BaseScheduler
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@ -9,9 +6,16 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
class SchedulerService:
|
class SchedulerService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.tcgplayer_service = TCGPlayerInventoryService()
|
|
||||||
self.file_processor = FileProcessingService()
|
|
||||||
self.scheduler = BaseScheduler()
|
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):
|
async def process_tcgplayer_export(self, export_type: str = "live", use_cache: bool = False):
|
||||||
"""
|
"""
|
||||||
@ -20,16 +24,20 @@ class SchedulerService:
|
|||||||
Args:
|
Args:
|
||||||
export_type: Type of export to process (staged, live, or pricing)
|
export_type: Type of export to process (staged, live, or pricing)
|
||||||
"""
|
"""
|
||||||
db = SessionLocal()
|
db = get_db()
|
||||||
try:
|
try:
|
||||||
logger.info(f"Starting scheduled TCGPlayer export processing for {export_type}")
|
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
|
# 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
|
# Process the file and load into database
|
||||||
with transaction(db):
|
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}")
|
logger.info(f"Completed TCGPlayer export processing: {stats}")
|
||||||
return stats
|
return stats
|
||||||
@ -37,9 +45,53 @@ class SchedulerService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing TCGPlayer export: {str(e)}")
|
logger.error(f"Error processing TCGPlayer export: {str(e)}")
|
||||||
raise
|
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):
|
async def start_scheduled_tasks(self):
|
||||||
"""Start all scheduled tasks"""
|
"""Start all scheduled tasks"""
|
||||||
# Schedule TCGPlayer export processing to run daily at 2 AM
|
# Schedule TCGPlayer export processing to run daily at 2 AM
|
||||||
@ -49,6 +101,27 @@ class SchedulerService:
|
|||||||
interval_seconds=24 * 60 * 60, # 24 hours
|
interval_seconds=24 * 60 * 60, # 24 hours
|
||||||
export_type="live"
|
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()
|
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
|
# Use the send helper which handles the complete print lifecycle
|
||||||
logger.info("Sending print data to printer...")
|
logger.info("Sending print data to printer...")
|
||||||
result = send(
|
try:
|
||||||
instructions=print_data,
|
result = send(
|
||||||
printer_identifier=self.printer_identifier,
|
instructions=print_data,
|
||||||
backend_identifier='pyusb',
|
printer_identifier=self.printer_identifier,
|
||||||
blocking=True
|
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}")
|
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