From 18b32c8514427e65b05ac6e2913bc7f587530ea3 Mon Sep 17 00:00:00 2001 From: zman Date: Sun, 13 Apr 2025 21:11:55 -0400 Subject: [PATCH] labels and stuff --- .gitignore | 3 +- alembic.ini | 2 +- .../2025_04_13_create_mtgjson_tables.py | 1 - .../2025_04_14_fix_alembic_version.py | 29 ++ .../2025_04_14_fix_foreign_key_issue.py | 32 ++ .../versions/2025_04_14_fix_mtgjson_final.py | 33 ++ .../2025_04_14_fix_mtgjson_foreign_key.py | 33 ++ .../2025_04_14_remove_mtgjson_foreign_key.py | 31 ++ .../versions/5bf5f87793d7_merge_all_heads.py | 26 ++ alembic/versions/d1628d8feb57_merge_heads.py | 26 ++ app.log | 20 +- app/data/assets/images/ccrcardsaddress.png | Bin 0 -> 28607 bytes .../templates/address_label_dk1201.html | 70 ++++ .../templates/address_label_dk1241.html | 105 ++++++ app/data/assets/templates/pull_sheet.html | 136 ++++++++ app/main.py | 55 ++- app/models/mtgjson_card.py | 6 +- app/models/mtgjson_sku.py | 8 +- app/services/address_label_service.py | 77 +++++ .../tcgplayer/base_tcgplayer_service.py | 15 +- .../tcgplayer/order_management_service.py | 81 +++++ app/services/label_printer_service.py | 231 +++++++++++++ app/services/print_service.py | 182 ++++++++++ app/services/pull_sheet_service.py | 83 +++++ app/services/regular_printer_service.py | 55 +++ printer_receiver.py | 312 ++++++++++++++++++ 26 files changed, 1627 insertions(+), 25 deletions(-) create mode 100644 alembic/versions/2025_04_14_fix_alembic_version.py create mode 100644 alembic/versions/2025_04_14_fix_foreign_key_issue.py create mode 100644 alembic/versions/2025_04_14_fix_mtgjson_final.py create mode 100644 alembic/versions/2025_04_14_fix_mtgjson_foreign_key.py create mode 100644 alembic/versions/2025_04_14_remove_mtgjson_foreign_key.py create mode 100644 alembic/versions/5bf5f87793d7_merge_all_heads.py create mode 100644 alembic/versions/d1628d8feb57_merge_heads.py create mode 100644 app/data/assets/images/ccrcardsaddress.png create mode 100644 app/data/assets/templates/address_label_dk1201.html create mode 100644 app/data/assets/templates/address_label_dk1241.html create mode 100644 app/data/assets/templates/pull_sheet.html create mode 100644 app/services/address_label_service.py create mode 100644 app/services/external_api/tcgplayer/order_management_service.py create mode 100644 app/services/label_printer_service.py create mode 100644 app/services/print_service.py create mode 100644 app/services/pull_sheet_service.py create mode 100644 app/services/regular_printer_service.py create mode 100644 printer_receiver.py diff --git a/.gitignore b/.gitignore index a47d56e..f4f4562 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ app/data/cache *.log -.env \ No newline at end of file +.env +alembic.ini diff --git a/alembic.ini b/alembic.ini index f175362..4dabebe 100644 --- a/alembic.ini +++ b/alembic.ini @@ -58,7 +58,7 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne # are written from script.py.mako # output_encoding = utf-8 -sqlalchemy.url = sqlite:///database.db +sqlalchemy.url = postgresql://poggers:giga!@192.168.1.41:5432/ai_giga_tcg [post_write_hooks] diff --git a/alembic/versions/2025_04_13_create_mtgjson_tables.py b/alembic/versions/2025_04_13_create_mtgjson_tables.py index ab8ef8d..79f059f 100644 --- a/alembic/versions/2025_04_13_create_mtgjson_tables.py +++ b/alembic/versions/2025_04_13_create_mtgjson_tables.py @@ -73,7 +73,6 @@ def upgrade() -> None: sa.Column('data', sa.JSON(), nullable=True), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint(['card_id'], ['mtgjson_cards.card_id'], ), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_mtgjson_skus_card_id'), 'mtgjson_skus', ['card_id'], unique=False) diff --git a/alembic/versions/2025_04_14_fix_alembic_version.py b/alembic/versions/2025_04_14_fix_alembic_version.py new file mode 100644 index 0000000..42b2dc2 --- /dev/null +++ b/alembic/versions/2025_04_14_fix_alembic_version.py @@ -0,0 +1,29 @@ +"""fix alembic version table + +Revision ID: 2025_04_14_fix_alembic_version +Revises: 4dbeb89dd33a +Create Date: 2025-04-14 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '2025_04_14_fix_alembic_version' +down_revision = '4dbeb89dd33a' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Increase the size of version_num column in alembic_version table + op.alter_column('alembic_version', 'version_num', + existing_type=sa.String(32), + type_=sa.String(255)) + + +def downgrade() -> None: + # Revert the column size back to 32 + op.alter_column('alembic_version', 'version_num', + existing_type=sa.String(255), + type_=sa.String(32)) \ No newline at end of file diff --git a/alembic/versions/2025_04_14_fix_foreign_key_issue.py b/alembic/versions/2025_04_14_fix_foreign_key_issue.py new file mode 100644 index 0000000..ea6c276 --- /dev/null +++ b/alembic/versions/2025_04_14_fix_foreign_key_issue.py @@ -0,0 +1,32 @@ +"""fix foreign key issue + +Revision ID: fix_foreign_key_issue +Revises: 5bf5f87793d7 +Create Date: 2025-04-14 04:15:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'fix_foreign_key_issue' +down_revision: Union[str, None] = '5bf5f87793d7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Drop the foreign key constraint if it exists + op.execute('ALTER TABLE mtgjson_skus DROP CONSTRAINT IF EXISTS mtgjson_skus_card_id_fkey') + # Make the column nullable + op.alter_column('mtgjson_skus', 'card_id', + existing_type=sa.String(), + nullable=True) + + +def downgrade() -> None: + # No downgrade - we don't want to recreate the constraint + pass \ No newline at end of file diff --git a/alembic/versions/2025_04_14_fix_mtgjson_final.py b/alembic/versions/2025_04_14_fix_mtgjson_final.py new file mode 100644 index 0000000..2c3ae94 --- /dev/null +++ b/alembic/versions/2025_04_14_fix_mtgjson_final.py @@ -0,0 +1,33 @@ +"""fix mtgjson final + +Revision ID: 2025_04_14_fix_mtgjson_final +Revises: d1628d8feb57 +Create Date: 2025-04-14 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '2025_04_14_fix_mtgjson_final' +down_revision = 'd1628d8feb57' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Drop the foreign key constraint and make card_id nullable + op.drop_constraint('mtgjson_skus_card_id_fkey', 'mtgjson_skus', type_='foreignkey') + op.alter_column('mtgjson_skus', 'card_id', + existing_type=sa.String(), + nullable=True) + + +def downgrade() -> None: + # Make card_id not nullable and recreate foreign key + op.alter_column('mtgjson_skus', 'card_id', + existing_type=sa.String(), + nullable=False) + op.create_foreign_key('mtgjson_skus_card_id_fkey', + 'mtgjson_skus', 'mtgjson_cards', + ['card_id'], ['card_id']) \ No newline at end of file diff --git a/alembic/versions/2025_04_14_fix_mtgjson_foreign_key.py b/alembic/versions/2025_04_14_fix_mtgjson_foreign_key.py new file mode 100644 index 0000000..f5cab6b --- /dev/null +++ b/alembic/versions/2025_04_14_fix_mtgjson_foreign_key.py @@ -0,0 +1,33 @@ +"""fix mtgjson foreign key + +Revision ID: 2025_04_14_fix_mtgjson_foreign_key +Revises: 4ad81b486caf +Create Date: 2025-04-14 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '2025_04_14_fix_mtgjson_foreign_key' +down_revision = '4ad81b486caf' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Drop the foreign key constraint and make card_id nullable + op.execute('ALTER TABLE mtgjson_skus DROP CONSTRAINT IF EXISTS mtgjson_skus_card_id_fkey') + op.alter_column('mtgjson_skus', 'card_id', + existing_type=sa.String(), + nullable=True) + + +def downgrade() -> None: + # Make card_id not nullable and recreate foreign key + op.alter_column('mtgjson_skus', 'card_id', + existing_type=sa.String(), + nullable=False) + op.create_foreign_key('mtgjson_skus_card_id_fkey', + 'mtgjson_skus', 'mtgjson_cards', + ['card_id'], ['card_id']) \ No newline at end of file diff --git a/alembic/versions/2025_04_14_remove_mtgjson_foreign_key.py b/alembic/versions/2025_04_14_remove_mtgjson_foreign_key.py new file mode 100644 index 0000000..37f6598 --- /dev/null +++ b/alembic/versions/2025_04_14_remove_mtgjson_foreign_key.py @@ -0,0 +1,31 @@ +"""remove mtgjson foreign key constraint + +Revision ID: 2025_04_14_remove_mtgjson_foreign_key +Revises: 2025_04_14_remove_mtgjson_data_columns +Create Date: 2025-04-14 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '2025_04_14_remove_mtgjson_foreign_key' +down_revision = '2025_04_14_remove_mtgjson_data_columns' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Drop the foreign key constraint from mtgjson_skus table + op.drop_constraint('mtgjson_skus_card_id_fkey', 'mtgjson_skus', type_='foreignkey') + + +def downgrade() -> None: + # Recreate the foreign key constraint + op.create_foreign_key( + 'mtgjson_skus_card_id_fkey', + 'mtgjson_skus', + 'mtgjson_cards', + ['card_id'], + ['card_id'] + ) \ No newline at end of file diff --git a/alembic/versions/5bf5f87793d7_merge_all_heads.py b/alembic/versions/5bf5f87793d7_merge_all_heads.py new file mode 100644 index 0000000..51f3d84 --- /dev/null +++ b/alembic/versions/5bf5f87793d7_merge_all_heads.py @@ -0,0 +1,26 @@ +"""merge all heads + +Revision ID: 5bf5f87793d7 +Revises: 2025_04_14_fix_alembic_version, 2025_04_14_fix_mtgjson_final +Create Date: 2025-04-13 00:12:47.613416 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '5bf5f87793d7' +down_revision: Union[str, None] = ('2025_04_14_fix_alembic_version', '2025_04_14_fix_mtgjson_final') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/d1628d8feb57_merge_heads.py b/alembic/versions/d1628d8feb57_merge_heads.py new file mode 100644 index 0000000..91a9192 --- /dev/null +++ b/alembic/versions/d1628d8feb57_merge_heads.py @@ -0,0 +1,26 @@ +"""merge heads + +Revision ID: d1628d8feb57 +Revises: 2025_04_14_fix_mtgjson_foreign_key, 2025_04_14_remove_mtgjson_foreign_key +Create Date: 2025-04-13 00:11:03.312552 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd1628d8feb57' +down_revision: Union[str, None] = ('2025_04_14_fix_mtgjson_foreign_key', '2025_04_14_remove_mtgjson_foreign_key') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/app.log b/app.log index 83bfeda..75fd9da 100644 --- a/app.log +++ b/app.log @@ -1,2 +1,18 @@ -2025-04-12 23:48:13,221 - INFO - app.main - Application starting up... -2025-04-12 23:48:16,568 - INFO - app.main - Database initialized successfully +2025-04-13 17:07:45,221 - INFO - app.main - Application starting up... +2025-04-13 17:07:45,277 - INFO - app.main - Database initialized successfully +2025-04-13 17:07:51,378 - INFO - app.services.label_printer_service - Converting PDF app/data/cache/tcgplayer/orders/packing_slip_2025-04-13.pdf to images +2025-04-13 17:07:51,467 - INFO - app.services.label_printer_service - Successfully converted PDF to 3 images +2025-04-13 17:07:51,467 - INFO - app.services.label_printer_service - Processing page 1 with dimensions (1700, 2200) +2025-04-13 17:08:10,149 - INFO - app.services.label_printer_service - Processing page 2 with dimensions (1700, 2200) +2025-04-13 17:08:14,940 - INFO - app.services.label_printer_service - Processing page 3 with dimensions (1700, 2200) +2025-04-13 17:08:19,947 - INFO - app.services.label_printer_service - Converting PDF app/data/cache/tcgplayer/address_labels/E576ED4C-491FAE-B9A96_dk1241.pdf to images +2025-04-13 17:08:19,992 - INFO - app.services.label_printer_service - Successfully converted PDF to 1 images +2025-04-13 17:08:19,992 - INFO - app.services.label_printer_service - Processing page 1 with dimensions (1200, 800) +2025-04-13 17:08:23,373 - INFO - app.services.label_printer_service - Converting PDF app/data/cache/tcgplayer/address_labels/E576ED4C-F69424-87265_dk1241.pdf to images +2025-04-13 17:08:23,415 - INFO - app.services.label_printer_service - Successfully converted PDF to 1 images +2025-04-13 17:08:23,415 - INFO - app.services.label_printer_service - Processing page 1 with dimensions (1200, 800) +2025-04-13 17:08:26,780 - INFO - app.services.label_printer_service - Converting PDF app/data/cache/tcgplayer/address_labels/E576ED4C-CD078B-CE881_dk1241.pdf to images +2025-04-13 17:08:26,822 - INFO - app.services.label_printer_service - Successfully converted PDF to 1 images +2025-04-13 17:08:26,823 - INFO - app.services.label_printer_service - Processing page 1 with dimensions (1200, 800) +2025-04-13 17:08:30,128 - INFO - app.main - TCGPlayer data initialized successfully +2025-04-13 17:08:30,128 - INFO - app.main - Scheduler started successfully diff --git a/app/data/assets/images/ccrcardsaddress.png b/app/data/assets/images/ccrcardsaddress.png new file mode 100644 index 0000000000000000000000000000000000000000..9e95460a600e0dce19c9a577bd08da17f8535d3c GIT binary patch literal 28607 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 + + + + + + +
+
+
{{ recipient_name }}
+
{{ address_line1 }}
+ {% if address_line2 %}
{{ address_line2 }}
{% endif %} +
{{ city }}, {{ state }} {{ zip_code }}
+
+
+ + \ No newline at end of file diff --git a/app/data/assets/templates/address_label_dk1241.html b/app/data/assets/templates/address_label_dk1241.html new file mode 100644 index 0000000..0ad9ab8 --- /dev/null +++ b/app/data/assets/templates/address_label_dk1241.html @@ -0,0 +1,105 @@ + + + + + + + +
+ Return Address + +
+ PLACE
STAMP
HERE
+
+ +
+
{{ recipient_name }}
+
{{ address_line1 }}
+ {% if address_line2 %}
{{ address_line2 }}
{% endif %} +
{{ city }}, {{ state }} {{ zip_code }}
+
+
+ + diff --git a/app/data/assets/templates/pull_sheet.html b/app/data/assets/templates/pull_sheet.html new file mode 100644 index 0000000..68ca5ab --- /dev/null +++ b/app/data/assets/templates/pull_sheet.html @@ -0,0 +1,136 @@ + + + + + + + +
+

Pull Sheet

+

Generated on {{ generation_date }}

+
+ + + + + + + + + + + + + {% for item in items %} + + + + + + + + {% endfor %} + +
Product NameConditionQtySetRarity
{{ item.product_name }}{{ item.condition }}{{ item.quantity }}{{ item.set }}{{ item.rarity }}
+ + \ No newline at end of file diff --git a/app/main.py b/app/main.py index 33eec5a..92b848e 100644 --- a/app/main.py +++ b/app/main.py @@ -9,6 +9,12 @@ from app.db.database import init_db, SessionLocal from app.services.scheduler.scheduler_service import SchedulerService from app.services.data_initialization import DataInitializationService from datetime import datetime +from app.services.external_api.tcgplayer.order_management_service import OrderManagementService +from app.services.address_label_service import AddressLabelService +from app.services.pull_sheet_service import PullSheetService +from app.services.print_service import PrintService +from app.services.label_printer_service import LabelPrinterService +from app.services.regular_printer_service import RegularPrinterService # Configure logging log_file = "app.log" if os.path.exists(log_file): @@ -37,7 +43,12 @@ logger.info("Application starting up...") # Initialize scheduler service scheduler_service = SchedulerService() data_init_service = DataInitializationService() - +order_management_service = OrderManagementService() +address_label_service = AddressLabelService() +pull_sheet_service = PullSheetService() +#print_service = PrintService(printer_name="MFCL2750DW-3", printer_api_url="http://192.168.1.110:8000/print") +label_printer_service = LabelPrinterService(printer_api_url="http://192.168.1.110:8000") +regular_printer_service = RegularPrinterService(printer_name="MFCL2750DW-3") @asynccontextmanager async def lifespan(app: FastAPI): # Startup @@ -47,16 +58,52 @@ async def lifespan(app: FastAPI): # Initialize TCGPlayer data db = SessionLocal() try: - await data_init_service.initialize_data(db, game_ids=[1, 3], init_archived_prices=False, archived_prices_start_date="2025-04-01", archived_prices_end_date=datetime.now().strftime("%Y-%m-%d"), init_categories=False, init_groups=False, init_products=False) # 1 = Magic, 3 = Pokemon + #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() + ready_orders = [order for order in orders if order.get("orderStatus") == "Ready to Ship"] + #logger.info(ready_orders) + + order_ids = [order.get("orderNumber") for order in ready_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 - await scheduler_service.start_scheduled_tasks() - await scheduler_service.process_tcgplayer_export(export_type="live", use_cache=True) + #await scheduler_service.start_scheduled_tasks() + #await scheduler_service.process_tcgplayer_export(export_type="live", use_cache=True) logger.info("Scheduler started successfully") yield diff --git a/app/models/mtgjson_card.py b/app/models/mtgjson_card.py index ead70af..58f27d1 100644 --- a/app/models/mtgjson_card.py +++ b/app/models/mtgjson_card.py @@ -1,6 +1,5 @@ from sqlalchemy import Column, Integer, String, DateTime from sqlalchemy.sql import func -from sqlalchemy.orm import relationship from app.db.database import Base class MTGJSONCard(Base): @@ -41,7 +40,4 @@ class MTGJSONCard(Base): tnt_id = Column(String, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.current_timestamp()) - updated_at = Column(DateTime(timezone=True), onupdate=func.current_timestamp()) - - # Relationships - skus = relationship("MTGJSONSKU", back_populates="card") \ No newline at end of file + updated_at = Column(DateTime(timezone=True), onupdate=func.current_timestamp()) \ No newline at end of file diff --git a/app/models/mtgjson_sku.py b/app/models/mtgjson_sku.py index 4997651..b6ea06a 100644 --- a/app/models/mtgjson_sku.py +++ b/app/models/mtgjson_sku.py @@ -1,6 +1,5 @@ -from sqlalchemy import Column, Integer, String, DateTime, ForeignKey +from sqlalchemy import Column, Integer, String, DateTime from sqlalchemy.sql import func -from sqlalchemy.orm import relationship from app.db.database import Base class MTGJSONSKU(Base): @@ -13,9 +12,6 @@ class MTGJSONSKU(Base): finish = Column(String) language = Column(String) printing = Column(String) - card_id = Column(String, ForeignKey("mtgjson_cards.card_id")) + card_id = Column(String, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.current_timestamp()) updated_at = Column(DateTime(timezone=True), onupdate=func.current_timestamp()) - - # Relationships - card = relationship("MTGJSONCard", back_populates="skus") \ No newline at end of file diff --git a/app/services/address_label_service.py b/app/services/address_label_service.py new file mode 100644 index 0000000..788667d --- /dev/null +++ b/app/services/address_label_service.py @@ -0,0 +1,77 @@ +from typing import List, Dict, Optional, Literal +import csv +import os +from pathlib import Path +from jinja2 import Environment, FileSystemLoader +from weasyprint import HTML + +class AddressLabelService: + def __init__(self): + self.template_dir = Path("app/data/assets/templates") + self.env = Environment(loader=FileSystemLoader(str(self.template_dir))) + self.templates = { + "dk1241": self.env.get_template("address_label_dk1241.html"), + "dk1201": self.env.get_template("address_label_dk1201.html") + } + self.return_address_path = "file://" + os.path.abspath("app/data/assets/images/ccrcardsaddress.png") + self.output_dir = "app/data/cache/tcgplayer/address_labels/" + os.makedirs(self.output_dir, exist_ok=True) + + def generate_labels_from_csv(self, csv_path: str, label_type: Literal["dk1201", "dk1241"]) -> List[str]: + """Generate address labels from a CSV file and save them as PDFs. + + Args: + csv_path: Path to the CSV file containing address data + label_type: Type of label to generate ("6x4" or "dk1201") + + Returns: + List of paths to generated PDF files + """ + generated_files = [] + + with open(csv_path, 'r') as csvfile: + reader = csv.DictReader(csvfile) + for row in reader: + # Generate label for each row + pdf_path = self._generate_single_label(row, label_type) + if pdf_path: + generated_files.append(str(pdf_path)) + + return generated_files + + def _generate_single_label(self, row: Dict[str, str], label_type: Literal["dk1201", "dk1241"]) -> Optional[str]: + """Generate a single address label PDF. + + Args: + row: Dictionary containing address data + label_type: Type of label to generate ("6x4" or "dk1201") + + Returns: + Path to the generated PDF file or None if generation failed + """ + try: + # Prepare template data + template_data = { + "recipient_name": f"{row['FirstName']} {row['LastName']}", + "address_line1": row['Address1'], + "address_line2": row['Address2'], + "city": row['City'], + "state": row['State'], + "zip_code": row['PostalCode'] + } + + # Add return address path only for 6x4 labels + if label_type == "dk1241": + template_data["return_address_path"] = self.return_address_path + + # Render HTML + html_content = self.templates[label_type].render(**template_data) + + # Generate PDF + pdf_path = self.output_dir + f"{row['Order #']}_{label_type}.pdf" + HTML(string=html_content).write_pdf(str(pdf_path)) + return pdf_path + + except Exception as e: + print(f"Error generating label for order {row.get('Order #', 'unknown')}: {str(e)}") + return None \ No newline at end of file diff --git a/app/services/external_api/tcgplayer/base_tcgplayer_service.py b/app/services/external_api/tcgplayer/base_tcgplayer_service.py index 4d97a98..2b31311 100644 --- a/app/services/external_api/tcgplayer/base_tcgplayer_service.py +++ b/app/services/external_api/tcgplayer/base_tcgplayer_service.py @@ -23,13 +23,19 @@ class BaseTCGPlayerService(BaseExternalService): self.credentials = TCGPlayerCredentials() - def _get_headers(self, method: str) -> Dict[str, str]: - """Get headers based on the HTTP method""" + def _get_headers(self, method: str, content_type: str = 'application/x-www-form-urlencoded') -> Dict[str, str]: + """Get headers based on the HTTP method and content type + + Args: + method: HTTP method (GET, POST, etc.) + content_type: Content type for the request. Defaults to 'application/x-www-form-urlencoded' + """ base_headers = { 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', 'accept-language': 'en-US,en;q=0.8', 'priority': 'u=0, i', - 'referer': 'https://store.tcgplayer.com/admin/pricing', + 'referer': 'https://sellerportal.tcgplayer.com/', + 'origin': 'https://sellerportal.tcgplayer.com', 'sec-ch-ua': '"Not A(Brand";v="8", "Chromium";v="132", "Brave";v="132"', 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"macOS"', @@ -45,8 +51,7 @@ class BaseTCGPlayerService(BaseExternalService): if method == 'POST': post_headers = { 'cache-control': 'max-age=0', - 'content-type': 'application/x-www-form-urlencoded', - 'origin': 'https://store.tcgplayer.com' + 'content-type': content_type } base_headers.update(post_headers) diff --git a/app/services/external_api/tcgplayer/order_management_service.py b/app/services/external_api/tcgplayer/order_management_service.py new file mode 100644 index 0000000..7a67d42 --- /dev/null +++ b/app/services/external_api/tcgplayer/order_management_service.py @@ -0,0 +1,81 @@ +from typing import Any, Dict, Optional, Union +import logging +from app.services.external_api.tcgplayer.base_tcgplayer_service import BaseTCGPlayerService +import os + +logger = logging.getLogger(__name__) + +class OrderManagementService(BaseTCGPlayerService): + ORDER_MANAGEMENT_BASE_URL = "https://order-management-api.tcgplayer.com/orders" + + def __init__(self): + super().__init__() + self.base_url = self.ORDER_MANAGEMENT_BASE_URL + self.API_VERSION: str = "?api-version=2.0" + self.SELLER_KEY: str = "e576ed4c" + + self.order_search_endpoint = f"/search{self.API_VERSION}" + self.packing_slip_endpoint = f"/packing-slips/export{self.API_VERSION}" + self.pull_sheet_endpoint = f"/pull-sheets/export{self.API_VERSION}" + self.shipping_endpoint = f"/shipping/export{self.API_VERSION}" + + + async def get_orders(self): + search_from = 0 + orders = [] + while True: + payload = { + "searchRange": "LastThreeMonths", + "filters": { + "sellerKey": self.SELLER_KEY + }, + "sortBy": [ + {"sortingType": "orderStatus", "direction": "ascending"}, + {"sortingType": "orderDate", "direction": "descending"} + ], + "from": search_from, + "size": 25 + } + response = await self._make_request("POST", self.order_search_endpoint, data=payload, headers=self._get_headers("POST", "application/json"), auth_required=True) + if len(response.get("orders")) == 0: + break + search_from += 25 + orders.extend(response.get("orders")) + return orders + + async def get_order(self, order_id: str): + response = await self._make_request("GET", f"{self.ORDER_MANAGEMENT_BASE_URL}/{order_id}{self.API_VERSION}") + return response + + async def get_packing_slip(self, order_ids: list[str]): + payload = { + "sortingType": "byRelease", + "format": "default", + "timezoneOffset": -4, + "orderNumbers": order_ids + } + response = await self._make_request("POST", self.packing_slip_endpoint, data=payload, headers=self._get_headers("POST", "application/json"), auth_required=True, download_file=True) + return response + async def get_pull_sheet(self, order_ids: list[str]): + payload = { + "orderNumbers": order_ids, + "timezoneOffset": -4 + } + response = await self._make_request("POST", self.pull_sheet_endpoint, data=payload, headers=self._get_headers("POST", "application/json"), auth_required=True, download_file=True) + return response + + async def get_shipping_csv(self, order_ids: list[str]): + payload = { + "orderNumbers": order_ids, + "timezoneOffset": -4 + } + response = await self._make_request("POST", self.shipping_endpoint, data=payload, headers=self._get_headers("POST", "application/json"), auth_required=True, download_file=True) + return response + + async def save_file(self, file_data: bytes, file_name: str) -> str: + if not os.path.exists("app/data/cache/tcgplayer/orders"): + os.makedirs("app/data/cache/tcgplayer/orders") + file_path = f"app/data/cache/tcgplayer/orders/{file_name}" + with open(file_path, "wb") as f: + f.write(file_data) + return file_path \ No newline at end of file diff --git a/app/services/label_printer_service.py b/app/services/label_printer_service.py new file mode 100644 index 0000000..d703ddf --- /dev/null +++ b/app/services/label_printer_service.py @@ -0,0 +1,231 @@ +from typing import Optional, Union, Literal +import aiohttp +from pathlib import Path +from pdf2image import convert_from_path +from brother_ql.conversion import convert +from brother_ql.raster import BrotherQLRaster +import logging +import asyncio +import time +from PIL import Image +from contextlib import asynccontextmanager + +logger = logging.getLogger(__name__) + +class LabelPrinterService: + def __init__(self, printer_api_url: str = "http://localhost:8000"): + """Initialize the label printer service. + + Args: + printer_api_url: Base URL of the printer API endpoint + """ + self.printer_api_url = printer_api_url.rstrip('/') + self.status_url = f"{self.printer_api_url}/status" + self.print_url = f"{self.printer_api_url}/print" + self.cache_dir = Path("app/data/cache/prints") + self.cache_dir.mkdir(parents=True, exist_ok=True) + self._session = None + self._lock = asyncio.Lock() + + @asynccontextmanager + async def _get_session(self): + """Context manager for aiohttp session.""" + if self._session is None: + self._session = aiohttp.ClientSession() + try: + yield self._session + except Exception as e: + logger.error(f"Session error: {e}") + if self._session: + await self._session.close() + self._session = None + raise + + async def _wait_for_printer_ready(self, max_wait: int = 300) -> bool: + """Wait for the printer to be ready. + + Args: + max_wait: Maximum time to wait in seconds + + Returns: + bool: True if printer is ready, False if timeout + """ + start_time = time.time() + while time.time() - start_time < max_wait: + try: + async with self._get_session() as session: + async with session.get(self.status_url) as response: + if response.status == 200: + data = await response.json() + if data.get('status') == 'ready': + return True + elif data.get('status') == 'busy': + logger.info("Printer is busy, waiting...") + elif response.status == 404: + logger.error(f"Printer status endpoint not found at {self.status_url}") + return False + except aiohttp.ClientError as e: + logger.warning(f"Error checking printer status: {e}") + except Exception as e: + logger.error(f"Unexpected error in _wait_for_printer_ready: {e}") + return False + + await asyncio.sleep(1) + + logger.error("Timeout waiting for printer to be ready") + return False + + async def _send_print_request(self, file_path: Union[str, Path]) -> bool: + """Send print data to printer API. + + Args: + file_path: Path to the binary data file to send to the printer + + Returns: + bool: True if request was successful, False otherwise + """ + try: + # Read the binary data from the cache file + with open(file_path, "rb") as f: + print_data = f.read() + + # Send the request to the printer API using aiohttp + async with self._get_session() as session: + async with session.post( + self.print_url, + data=print_data, + headers={'Content-Type': 'application/octet-stream'}, + timeout=30 + ) as response: + if response.status == 200: + data = await response.json() + if data.get('message') == 'Print request processed successfully': + return True + logger.error(f"Unexpected success response: {data}") + return False + elif response.status == 404: + logger.error(f"Print endpoint not found at {self.print_url}") + return False + elif response.status == 429: + logger.error("Printer is busy") + return False + else: + data = await response.json() + logger.error(f"Print request failed with status {response.status}: {data.get('message')}") + return False + + except aiohttp.ClientError as e: + logger.error(f"Error sending print request: {str(e)}") + return False + except Exception as e: + logger.error(f"Unexpected error in _send_print_request: {e}") + return False + + async def print_file(self, file_path: Union[str, Path], label_size: Literal["dk1201", "dk1241"], label_type: Optional[Literal["address_label", "packing_slip"]] = None) -> bool: + """Print a PDF or PNG file to the label printer. + + Args: + file_path: Path to the PDF or PNG file + label_size: Size of label to use ("dk1201" or "dk1241") + label_type: Type of label to use ("address_label" or "packing_slip") + + Returns: + bool: True if print was successful, False otherwise + """ + async with self._lock: # Ensure only one print operation at a time + try: + if file_path is None: + logger.error("No file path provided") + return False + + file_path = Path(file_path) + if not file_path.exists(): + logger.error(f"File not found: {file_path}") + return False + + # Wait for printer to be ready + if not await self._wait_for_printer_ready(): + logger.error("Printer not ready after waiting") + return False + + if file_path.suffix.lower() == '.pdf': + # Convert PDF to images + logger.info(f"Converting PDF {file_path} to images") + images = convert_from_path(file_path) + if not images: + logger.error(f"No images could be extracted from {file_path}") + return False + logger.info(f"Successfully converted PDF to {len(images)} images") + else: + # For PNG files, we can use them directly + images = [Image.open(file_path)] + + # Process each page + for i, image in enumerate(images): + logger.info(f"Processing page {i+1} with dimensions {image.size}") + # Resize image based on label size and type + resized_image = image.copy() # Create a copy to work with + + # Store the original label size before we modify it + original_label_size = label_size + + # Handle resizing based on label size and type + if original_label_size == "dk1241": + if label_type == "packing_slip": + resized_image = resized_image.resize((1164, 1660), Image.Resampling.LANCZOS) + elif label_type == "address_label": + resized_image = resized_image.resize((1660, 1164), Image.Resampling.LANCZOS) + else: + resized_image = resized_image.resize((1164, 1660), Image.Resampling.LANCZOS) + elif original_label_size == "dk1201" and label_type == "address_label": + resized_image = resized_image.resize((991, 306), Image.Resampling.LANCZOS) + + # if file path contains address_label, rotate image 90 degrees + if label_type == "address_label": + rotate = "90" + else: + rotate = "0" + + # Convert to label format + qlr = BrotherQLRaster("QL-1100") + qlr.exception_on_warning = True + + # Get label size based on type + brother_label_size = "29x90" if original_label_size == "dk1201" else "102x152" + + converted_image = convert( + qlr=qlr, + images=[resized_image], + label=brother_label_size, + rotate=rotate, + threshold=70.0, + dither=False, + compress=False, + red=False, + dpi_600=False, + #hq=True, + hq=False, + cut=True + ) + + # Cache the converted binary data + cache_path = self.cache_dir / f"{file_path.stem}_{brother_label_size}_page_{i+1}_converted.bin" + with open(cache_path, "wb") as f: + f.write(converted_image) + + # Send to API + if not await self._send_print_request(cache_path): + logger.error(f"Failed to print page {i+1}") + return False + + # Wait for printer to be ready before processing next page + if i < len(images) - 1: # Don't wait after the last page + if not await self._wait_for_printer_ready(): + logger.error("Printer not ready for next page") + return False + + return True + + except Exception as e: + logger.error(f"Error printing file {file_path}: {str(e)}") + return False \ No newline at end of file diff --git a/app/services/print_service.py b/app/services/print_service.py new file mode 100644 index 0000000..6ac5f8a --- /dev/null +++ b/app/services/print_service.py @@ -0,0 +1,182 @@ +from typing import Optional, Union, Literal +import os +import aiohttp +import cups +from pathlib import Path +from pdf2image import convert_from_path +from brother_ql.conversion import convert +from brother_ql.raster import BrotherQLRaster +import logging +import asyncio +import time +from datetime import datetime, timedelta + +logger = logging.getLogger(__name__) + +class PrintService: + def __init__(self, printer_name: Optional[str] = None, printer_api_url: str = "http://localhost:8000/print", + min_print_interval: int = 30): + """Initialize the print service. + + Args: + printer_name: Name of the printer to use. If None, will use default printer. + printer_api_url: URL of the printer API endpoint + min_print_interval: Minimum time in seconds between print requests for label printer + """ + self.printer_name = printer_name + self.printer_api_url = printer_api_url + self.status_url = printer_api_url.replace('/print', '/status') + self.conn = cups.Connection() + self.cache_dir = Path("app/data/cache/prints") + self.cache_dir.mkdir(parents=True, exist_ok=True) + + # Rate limiting and coordination + self.min_print_interval = min_print_interval + self._last_print_time = None + self._print_lock = asyncio.Lock() + + async def _wait_for_printer_ready(self, max_wait: int = 300) -> bool: + """Wait for the printer to be ready. + + Args: + max_wait: Maximum time to wait in seconds + + Returns: + bool: True if printer is ready, False if timeout + """ + start_time = time.time() + while time.time() - start_time < max_wait: + try: + async with aiohttp.ClientSession() as session: + async with session.get(self.status_url) as response: + if response.status == 200: + data = await response.json() + if data.get('status') == 'ready': + return True + except Exception as e: + logger.warning(f"Error checking printer status: {e}") + + await asyncio.sleep(1) + + logger.error("Timeout waiting for printer to be ready") + return False + + async def print_file(self, file_path: Union[str, Path], printer_type: Literal["regular", "label"] = "regular", label_type: Optional[Literal["dk1201", "dk1241"]] = None) -> bool: + """Print a PDF or PNG file. + + Args: + file_path: Path to the PDF or PNG file + printer_type: Type of printer ("regular" or "label") + label_type: Type of label to use ("dk1201" or "dk1241"). Only used when printer_type is "label". + + Returns: + bool: True if print was successful, False otherwise + """ + try: + file_path = Path(file_path) + if not file_path.exists(): + logger.error(f"File not found: {file_path}") + return False + + if printer_type == "regular": + # For regular printers, use CUPS + printer = self.printer_name or self.conn.getDefault() + if not printer: + logger.error("No default printer found") + return False + + job_id = self.conn.printFile( + printer, + str(file_path), + f"{file_path.suffix.upper()} Print", + {} + ) + logger.info(f"Print job {job_id} submitted to printer {printer}") + return True + else: + # For label printers, we need to coordinate requests + if label_type is None: + logger.error("label_type must be specified when printing to label printer") + return False + + # Wait for printer to be ready + if not await self._wait_for_printer_ready(): + logger.error("Printer not ready after waiting") + return False + + if file_path.suffix.lower() == '.pdf': + # Convert PDF to image first + images = convert_from_path(file_path) + if not images: + logger.error(f"No images could be extracted from {file_path}") + return False + image = images[0] # Only use first page + else: + # For PNG files, we can use them directly + from PIL import Image + image = Image.open(file_path) + + # Convert to label format + qlr = BrotherQLRaster("QL-1100") + qlr.exception_on_warning = True + + # Get label size based on type + label_size = "29x90" if label_type == "dk1201" else "102x152" + + converted_image = convert( + qlr=qlr, + images=[image], + label=label_size, + rotate="0", + threshold=70.0, + dither=False, + compress=False, + red=False, + dpi_600=False, + hq=True, + cut=True + ) + + # Cache the converted binary data + cache_path = self.cache_dir / f"{file_path.stem}_{label_type}_converted.bin" + with open(cache_path, "wb") as f: + f.write(converted_image) + + # Send to API + return await self._send_print_request(cache_path) + + except Exception as e: + logger.error(f"Error printing file {file_path}: {str(e)}") + return False + + async def _send_print_request(self, file_path: Union[str, Path]) -> bool: + """Send print data to printer API. + + Args: + file_path: Path to the binary data file to send to the printer + + Returns: + bool: True if request was successful, False otherwise + """ + try: + # Read the binary data from the cache file + with open(file_path, "rb") as f: + print_data = f.read() + + # Send the request to the printer API using aiohttp + async with aiohttp.ClientSession() as session: + async with session.post( + self.printer_api_url, + data=print_data, + timeout=30 + ) as response: + if response.status == 200: + return True + else: + response_text = await response.text() + logger.error(f"Print request failed with status {response.status}: {response_text}") + return False + + except Exception as e: + logger.error(f"Error sending print request: {str(e)}") + return False diff --git a/app/services/pull_sheet_service.py b/app/services/pull_sheet_service.py new file mode 100644 index 0000000..8b7a89b --- /dev/null +++ b/app/services/pull_sheet_service.py @@ -0,0 +1,83 @@ +from typing import List, Dict +import pandas as pd +from datetime import datetime +from pathlib import Path +from jinja2 import Environment, FileSystemLoader +from weasyprint import HTML +import logging + +logger = logging.getLogger(__name__) + +class PullSheetService: + def __init__(self): + self.template_dir = Path("app/data/assets/templates") + self.env = Environment(loader=FileSystemLoader(str(self.template_dir))) + self.template = self.env.get_template("pull_sheet.html") + self.output_dir = Path("app/data/cache/tcgplayer/pull_sheets") + self.output_dir.mkdir(parents=True, exist_ok=True) + + def generate_pull_sheet_pdf(self, csv_path: str) -> str: + """Generate a PDF pull sheet from a CSV file. + + Args: + csv_path: Path to the CSV file containing pull sheet data + + Returns: + Path to the generated PDF file + """ + try: + # Read and process CSV data + items = self._read_and_process_csv(csv_path) + + # Prepare template data + template_data = { + 'items': items, + 'generation_date': datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + + # Render HTML + html_content = self.template.render(**template_data) + + # Generate 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)) + + return str(pdf_path) + + except Exception as e: + logger.error(f"Error generating pull sheet PDF: {str(e)}") + raise + + def _read_and_process_csv(self, csv_path: str) -> List[Dict]: + """Read and process CSV data using pandas. + + Args: + csv_path: Path to the CSV file + + Returns: + List of processed items + """ + # Read CSV into pandas DataFrame + df = pd.read_csv(csv_path) + + # Filter out the "Orders Contained in Pull Sheet" row + df = df[df['Product Line'] != 'Orders Contained in Pull Sheet:'] + + # Convert Set Release Date to datetime + df['Set Release Date'] = pd.to_datetime(df['Set Release Date'], format='%m/%d/%Y %H:%M:%S') + + # Sort by Set Release Date (descending) and then Product Name (ascending) + df = df.sort_values(['Set Release Date', 'Product Name'], ascending=[False, True]) + + # Convert to list of dictionaries + items = [] + for _, row in df.iterrows(): + items.append({ + 'product_name': row['Product Name'], + 'condition': row['Condition'], + 'quantity': str(int(row['Quantity'])), # Convert to string for template + 'set': row['Set'], + 'rarity': row['Rarity'] + }) + + return items \ No newline at end of file diff --git a/app/services/regular_printer_service.py b/app/services/regular_printer_service.py new file mode 100644 index 0000000..22e57e9 --- /dev/null +++ b/app/services/regular_printer_service.py @@ -0,0 +1,55 @@ +from typing import Optional, Union +import cups +from pathlib import Path +import logging + +logger = logging.getLogger(__name__) + +class RegularPrinterService: + def __init__(self, printer_name: Optional[str] = None): + """Initialize the regular printer service. + + Args: + printer_name: Name of the printer to use. If None, will use default printer. + """ + self.printer_name = printer_name + self.conn = cups.Connection() + + async def print_file(self, file_path: Union[str, Path]) -> bool: + """Print a PDF or PNG file to the regular printer. + + Args: + file_path: Path to the PDF or PNG file + + Returns: + bool: True if print was successful, False otherwise + """ + try: + if file_path is None: + logger.error("No file path provided") + return False + + file_path = Path(file_path) + if not file_path.exists(): + logger.error(f"File not found: {file_path}") + return False + + # Get the printer + printer = self.printer_name or self.conn.getDefault() + if not printer: + logger.error("No default printer found") + return False + + # Submit the print job + job_id = self.conn.printFile( + printer, + str(file_path), + f"{file_path.suffix.upper()} Print", + {} + ) + logger.info(f"Print job {job_id} submitted to printer {printer}") + return True + + except Exception as e: + logger.error(f"Error printing file {file_path}: {str(e)}") + return False \ No newline at end of file diff --git a/printer_receiver.py b/printer_receiver.py new file mode 100644 index 0000000..8db0274 --- /dev/null +++ b/printer_receiver.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +from flask import Flask, request, jsonify +from brother_ql.backends import backend_factory +from brother_ql.backends.helpers import discover, status, send +from brother_ql.raster import BrotherQLRaster +import logging +import sys +import time +from typing import Optional + +# Configure logging +logging.basicConfig( + level=logging.DEBUG, # Changed to DEBUG for more detailed logging + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +class LabelPrinterReceiver: + def __init__(self, printer_identifier: str = "usb://0x04f9:0x20a7"): + """Initialize the label printer receiver. + + Args: + printer_identifier: USB identifier for the printer + """ + logger.info(f"Initializing LabelPrinterReceiver with printer identifier: {printer_identifier}") + self.printer_identifier = printer_identifier + self.backend = None + self._print_lock = False + self._last_print_time = None + self._print_in_progress = False + self._last_known_state = None + self._last_state_time = None + self._state_cache_timeout = 1.0 # seconds + + def connect_printer(self) -> bool: + """Connect to the printer via USB. + + Returns: + bool: True if connection was successful, False otherwise + """ + logger.info("Attempting to connect to printer...") + try: + # Get the backend class from the factory + backend_info = backend_factory('pyusb') + logger.debug(f"Backend info: {backend_info}") + self.backend = backend_info['backend_class'](device_specifier=self.printer_identifier) + + # Connect to the printer + self.backend.write = self.backend.write # This triggers the connection + logger.info("Successfully connected to printer") + return True + + except Exception as e: + logger.error(f"Error connecting to printer: {e}") + self.backend = None + return False + + def is_printer_busy(self) -> bool: + """Check if the printer is currently busy. + + Returns: + bool: True if printer is busy, False otherwise + """ + logger.debug("Checking printer status...") + + # Check if we have a recent cached state + if (self._last_known_state is not None and + self._last_state_time is not None and + time.time() - self._last_state_time < self._state_cache_timeout): + logger.debug("Using cached printer state") + return self._last_known_state.get('phase_type') != 'Waiting to receive' + + try: + if not self.backend: + logger.warning("No backend connection, attempting to connect...") + if not self.connect_printer(): + logger.error("Failed to connect to printer") + return False + + # Get actual printer status + logger.debug("Requesting printer status...") + status_info, raw_data = status(printer_identifier=self.printer_identifier) + logger.debug(f"Raw status data: {raw_data}") + logger.debug(f"Parsed status info: {status_info}") + + # Cache the state + self._last_known_state = status_info + self._last_state_time = time.time() + + # Check for any errors + if status_info.get('errors'): + logger.error(f"Printer errors detected: {status_info['errors']}") + return False + + # Check printer phase + phase_type = status_info.get('phase_type') + logger.debug(f"Printer phase type: {phase_type}") + + if phase_type == 'Waiting to receive': + logger.info("Printer is ready for next job") + return False + elif phase_type == 'Printing': + logger.info("Printer is currently printing") + return True + else: + logger.info(f"Printer is in unknown phase: {phase_type}") + return True # Assume busy for unknown phases + + except Exception as e: + logger.error(f"Error getting printer status: {e}") + # If we get an error, clear the cached state + self._last_known_state = None + self._last_state_time = None + return False + + def handle_print_request(self) -> tuple: + """Handle incoming print requests. + + Returns: + tuple: (response message, HTTP status code) + """ + logger.info("Received print request") + if self._print_lock: + logger.warning("Print lock is active, rejecting request") + return {"message": "Another print job is being processed"}, 429 + + try: + logger.info("Acquiring print lock") + self._print_lock = True + self._print_in_progress = True + self._last_print_time = time.time() + + # Get the print data from the request + print_data = request.get_data() + if not print_data: + logger.error("No print data provided in request") + return {"message": "No print data provided"}, 400 + logger.info(f"Received print data of size: {len(print_data)} bytes") + + # Use the send helper which handles the complete print lifecycle + logger.info("Sending print data to printer...") + result = send( + instructions=print_data, + printer_identifier=self.printer_identifier, + backend_identifier='pyusb', + blocking=True + ) + + logger.debug(f"Print result: {result}") + + # Update cached state with the result + if 'printer_state' in result: + self._last_known_state = result['printer_state'] + self._last_state_time = time.time() + + if result['outcome'] == 'error': + logger.error(f"Print error: {result.get('printer_state', {}).get('errors', 'Unknown error')}") + return {"message": f"Print error: {result.get('printer_state', {}).get('errors', 'Unknown error')}"}, 500 + + if not result['did_print']: + logger.warning("Print may not have completed successfully") + return {"message": "Print sent but completion status unclear"}, 200 + + logger.info("Print completed successfully") + return {"message": "Print request processed successfully"}, 200 + + except Exception as e: + logger.error(f"Error processing print request: {e}") + # Clear cached state on error + self._last_known_state = None + self._last_state_time = None + return {"message": f"Error: {str(e)}"}, 500 + finally: + logger.info("Releasing print lock") + self._print_lock = False + self._print_in_progress = False + + def start_server(self, host: str = "0.0.0.0", port: int = 8000): + """Start the web server to receive print requests. + + Args: + host: Host to bind the server to + port: Port to listen on + """ + logger.info(f"Starting print server on {host}:{port}") + app = Flask(__name__) + + @app.route('/print', methods=['POST']) + def print_endpoint(): + logger.info("Received print request at /print endpoint") + response, status_code = self.handle_print_request() + logger.info(f"Print request completed with status {status_code}: {response}") + return jsonify(response), status_code + + @app.route('/status', methods=['GET']) + def status_endpoint(): + logger.info("Received status request at /status endpoint") + try: + if not self.backend: + if not self.connect_printer(): + logger.error("Failed to connect to printer for status check") + return jsonify({"status": "error", "message": "Could not connect to printer"}), 500 + + # Get actual printer status with retry logic + max_retries = 3 + retry_delay = 1 # seconds + last_error = None + + for attempt in range(max_retries): + try: + logger.debug(f"Requesting printer status (attempt {attempt + 1}/{max_retries})...") + status_info, raw_data = status(printer_identifier=self.printer_identifier) + logger.debug(f"Raw status data: {raw_data}") + logger.debug(f"Parsed status info: {status_info}") + + # Check for any errors + if status_info.get('errors'): + logger.error(f"Printer errors detected: {status_info['errors']}") + return jsonify({ + "status": "error", + "message": f"Printer errors: {status_info['errors']}" + }), 500 + + # Check printer phase + phase_type = status_info.get('phase_type') + logger.debug(f"Printer phase type: {phase_type}") + + # Convert status info to JSON-serializable format + serializable_status = { + 'status_type': status_info.get('status_type'), + 'phase_type': phase_type, + 'model_name': status_info.get('model_name'), + 'media_type': status_info.get('media_type'), + 'media_width': status_info.get('media_width'), + 'media_length': status_info.get('media_length'), + 'errors': status_info.get('errors', []) + } + + # Add media info if available + if 'identified_media' in status_info: + media = status_info['identified_media'] + serializable_status['media'] = { + 'identifier': media.identifier, + 'tape_size': media.tape_size, + 'form_factor': str(media.form_factor), + 'color': str(media.color) + } + + if phase_type == 'Waiting to receive': + logger.info("Printer status: ready") + return jsonify({ + "status": "ready", + "phase": phase_type, + "details": serializable_status + }), 200 + elif phase_type == 'Printing': + logger.info("Printer status: busy") + return jsonify({ + "status": "busy", + "phase": phase_type, + "details": serializable_status + }), 200 + else: + logger.info(f"Printer is in unknown phase: {phase_type}") + return jsonify({ + "status": "busy", + "phase": phase_type, + "details": serializable_status + }), 200 + + except Exception as e: + last_error = e + if "Resource busy" in str(e): + logger.warning(f"Printer is busy, retrying in {retry_delay} seconds...") + time.sleep(retry_delay) + retry_delay *= 2 # Exponential backoff + else: + logger.error(f"Error checking printer status: {e}") + return jsonify({ + "status": "error", + "message": str(e) + }), 500 + + # If we've exhausted all retries + logger.error(f"Failed to get printer status after {max_retries} attempts. Last error: {last_error}") + return jsonify({ + "status": "error", + "message": f"Printer is busy and not responding. Last error: {str(last_error)}" + }), 503 # Service Unavailable + + except Exception as e: + logger.error(f"Error checking printer status: {e}") + return jsonify({ + "status": "error", + "message": str(e) + }), 500 + + logger.info(f"Print server started successfully on {host}:{port}") + app.run(host=host, port=port) + +def main(): + # Create and start the printer receiver + logger.info("Starting printer receiver...") + receiver = LabelPrinterReceiver() + receiver.start_server() + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + logger.info("Shutting down print server") + sys.exit(0) \ No newline at end of file