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 0000000..9e95460 Binary files /dev/null and b/app/data/assets/images/ccrcardsaddress.png differ diff --git a/app/data/assets/templates/address_label_dk1201.html b/app/data/assets/templates/address_label_dk1201.html new file mode 100644 index 0000000..37051f6 --- /dev/null +++ b/app/data/assets/templates/address_label_dk1201.html @@ -0,0 +1,70 @@ + + + + + + + +
+
+
{{ 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