feat: add Letzshop bidirectional order integration
Add complete Letzshop marketplace integration with: - GraphQL client for order import and fulfillment operations - Encrypted credential storage per vendor (Fernet encryption) - Admin and vendor API endpoints for credentials management - Order import, confirmation, rejection, and tracking - Fulfillment queue and sync logging - Comprehensive documentation and test coverage New files: - app/services/letzshop/ - GraphQL client and services - app/utils/encryption.py - Fernet encryption utility - models/database/letzshop.py - Database models - models/schema/letzshop.py - Pydantic schemas - app/api/v1/admin/letzshop.py - Admin API endpoints - app/api/v1/vendor/letzshop.py - Vendor API endpoints - docs/guides/letzshop-order-integration.md - Documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@ from . import (
|
||||
companies,
|
||||
content_pages,
|
||||
dashboard,
|
||||
letzshop,
|
||||
logs,
|
||||
marketplace,
|
||||
monitoring,
|
||||
@@ -114,6 +115,9 @@ router.include_router(vendor_products.router, tags=["admin-vendor-products"])
|
||||
# Include marketplace monitoring endpoints
|
||||
router.include_router(marketplace.router, tags=["admin-marketplace"])
|
||||
|
||||
# Include Letzshop integration endpoints
|
||||
router.include_router(letzshop.router, tags=["admin-letzshop"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Platform Administration
|
||||
|
||||
470
app/api/v1/admin/letzshop.py
Normal file
470
app/api/v1/admin/letzshop.py
Normal file
@@ -0,0 +1,470 @@
|
||||
# app/api/v1/admin/letzshop.py
|
||||
"""
|
||||
Admin API endpoints for Letzshop marketplace integration.
|
||||
|
||||
Provides admin-level management of:
|
||||
- Per-vendor Letzshop credentials
|
||||
- Connection testing
|
||||
- Sync triggers and status
|
||||
- Order overview
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import ResourceNotFoundException, ValidationException
|
||||
from app.services.letzshop import (
|
||||
CredentialsNotFoundError,
|
||||
LetzshopClientError,
|
||||
LetzshopCredentialsService,
|
||||
LetzshopOrderService,
|
||||
VendorNotFoundError,
|
||||
)
|
||||
from models.database.user import User
|
||||
from models.schema.letzshop import (
|
||||
LetzshopConnectionTestRequest,
|
||||
LetzshopConnectionTestResponse,
|
||||
LetzshopCredentialsCreate,
|
||||
LetzshopCredentialsResponse,
|
||||
LetzshopCredentialsUpdate,
|
||||
LetzshopOrderListResponse,
|
||||
LetzshopOrderResponse,
|
||||
LetzshopSuccessResponse,
|
||||
LetzshopSyncTriggerRequest,
|
||||
LetzshopSyncTriggerResponse,
|
||||
LetzshopVendorListResponse,
|
||||
LetzshopVendorOverview,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/letzshop")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_order_service(db: Session) -> LetzshopOrderService:
|
||||
"""Get order service instance."""
|
||||
return LetzshopOrderService(db)
|
||||
|
||||
|
||||
def get_credentials_service(db: Session) -> LetzshopCredentialsService:
|
||||
"""Get credentials service instance."""
|
||||
return LetzshopCredentialsService(db)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Vendor Overview
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/vendors", response_model=LetzshopVendorListResponse)
|
||||
def list_vendors_letzshop_status(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
configured_only: bool = Query(False, description="Only show vendors with Letzshop configured"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
List all vendors with their Letzshop integration status.
|
||||
|
||||
Shows which vendors have Letzshop configured, sync status, and pending orders.
|
||||
"""
|
||||
order_service = get_order_service(db)
|
||||
vendor_overviews, total = order_service.list_vendors_with_letzshop_status(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
configured_only=configured_only,
|
||||
)
|
||||
|
||||
return LetzshopVendorListResponse(
|
||||
vendors=[LetzshopVendorOverview(**v) for v in vendor_overviews],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Credentials Management
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/vendors/{vendor_id}/credentials",
|
||||
response_model=LetzshopCredentialsResponse,
|
||||
)
|
||||
def get_vendor_credentials(
|
||||
vendor_id: int = Path(..., description="Vendor ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get Letzshop credentials for a vendor (API key is masked)."""
|
||||
order_service = get_order_service(db)
|
||||
creds_service = get_credentials_service(db)
|
||||
|
||||
try:
|
||||
vendor = order_service.get_vendor_or_raise(vendor_id)
|
||||
except VendorNotFoundError:
|
||||
raise ResourceNotFoundException("Vendor", str(vendor_id))
|
||||
|
||||
try:
|
||||
credentials = creds_service.get_credentials_or_raise(vendor_id)
|
||||
except CredentialsNotFoundError:
|
||||
raise ResourceNotFoundException(
|
||||
"LetzshopCredentials", str(vendor_id),
|
||||
message=f"Letzshop credentials not configured for vendor {vendor.name}"
|
||||
)
|
||||
|
||||
return LetzshopCredentialsResponse(
|
||||
id=credentials.id,
|
||||
vendor_id=credentials.vendor_id,
|
||||
api_key_masked=creds_service.get_masked_api_key(vendor_id),
|
||||
api_endpoint=credentials.api_endpoint,
|
||||
auto_sync_enabled=credentials.auto_sync_enabled,
|
||||
sync_interval_minutes=credentials.sync_interval_minutes,
|
||||
last_sync_at=credentials.last_sync_at,
|
||||
last_sync_status=credentials.last_sync_status,
|
||||
last_sync_error=credentials.last_sync_error,
|
||||
created_at=credentials.created_at,
|
||||
updated_at=credentials.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/vendors/{vendor_id}/credentials",
|
||||
response_model=LetzshopCredentialsResponse,
|
||||
)
|
||||
def create_or_update_vendor_credentials(
|
||||
vendor_id: int = Path(..., description="Vendor ID"),
|
||||
credentials_data: LetzshopCredentialsCreate = ...,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Create or update Letzshop credentials for a vendor."""
|
||||
order_service = get_order_service(db)
|
||||
creds_service = get_credentials_service(db)
|
||||
|
||||
try:
|
||||
vendor = order_service.get_vendor_or_raise(vendor_id)
|
||||
except VendorNotFoundError:
|
||||
raise ResourceNotFoundException("Vendor", str(vendor_id))
|
||||
|
||||
credentials = creds_service.upsert_credentials(
|
||||
vendor_id=vendor_id,
|
||||
api_key=credentials_data.api_key,
|
||||
api_endpoint=credentials_data.api_endpoint,
|
||||
auto_sync_enabled=credentials_data.auto_sync_enabled,
|
||||
sync_interval_minutes=credentials_data.sync_interval_minutes,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_admin.email} updated Letzshop credentials for vendor {vendor.name}"
|
||||
)
|
||||
|
||||
return LetzshopCredentialsResponse(
|
||||
id=credentials.id,
|
||||
vendor_id=credentials.vendor_id,
|
||||
api_key_masked=creds_service.get_masked_api_key(vendor_id),
|
||||
api_endpoint=credentials.api_endpoint,
|
||||
auto_sync_enabled=credentials.auto_sync_enabled,
|
||||
sync_interval_minutes=credentials.sync_interval_minutes,
|
||||
last_sync_at=credentials.last_sync_at,
|
||||
last_sync_status=credentials.last_sync_status,
|
||||
last_sync_error=credentials.last_sync_error,
|
||||
created_at=credentials.created_at,
|
||||
updated_at=credentials.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/vendors/{vendor_id}/credentials",
|
||||
response_model=LetzshopCredentialsResponse,
|
||||
)
|
||||
def update_vendor_credentials(
|
||||
vendor_id: int = Path(..., description="Vendor ID"),
|
||||
credentials_data: LetzshopCredentialsUpdate = ...,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Partially update Letzshop credentials for a vendor."""
|
||||
order_service = get_order_service(db)
|
||||
creds_service = get_credentials_service(db)
|
||||
|
||||
try:
|
||||
vendor = order_service.get_vendor_or_raise(vendor_id)
|
||||
except VendorNotFoundError:
|
||||
raise ResourceNotFoundException("Vendor", str(vendor_id))
|
||||
|
||||
try:
|
||||
credentials = creds_service.update_credentials(
|
||||
vendor_id=vendor_id,
|
||||
api_key=credentials_data.api_key,
|
||||
api_endpoint=credentials_data.api_endpoint,
|
||||
auto_sync_enabled=credentials_data.auto_sync_enabled,
|
||||
sync_interval_minutes=credentials_data.sync_interval_minutes,
|
||||
)
|
||||
except CredentialsNotFoundError:
|
||||
raise ResourceNotFoundException(
|
||||
"LetzshopCredentials", str(vendor_id),
|
||||
message=f"Letzshop credentials not configured for vendor {vendor.name}"
|
||||
)
|
||||
|
||||
return LetzshopCredentialsResponse(
|
||||
id=credentials.id,
|
||||
vendor_id=credentials.vendor_id,
|
||||
api_key_masked=creds_service.get_masked_api_key(vendor_id),
|
||||
api_endpoint=credentials.api_endpoint,
|
||||
auto_sync_enabled=credentials.auto_sync_enabled,
|
||||
sync_interval_minutes=credentials.sync_interval_minutes,
|
||||
last_sync_at=credentials.last_sync_at,
|
||||
last_sync_status=credentials.last_sync_status,
|
||||
last_sync_error=credentials.last_sync_error,
|
||||
created_at=credentials.created_at,
|
||||
updated_at=credentials.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/vendors/{vendor_id}/credentials",
|
||||
response_model=LetzshopSuccessResponse,
|
||||
)
|
||||
def delete_vendor_credentials(
|
||||
vendor_id: int = Path(..., description="Vendor ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Delete Letzshop credentials for a vendor."""
|
||||
order_service = get_order_service(db)
|
||||
creds_service = get_credentials_service(db)
|
||||
|
||||
try:
|
||||
vendor = order_service.get_vendor_or_raise(vendor_id)
|
||||
except VendorNotFoundError:
|
||||
raise ResourceNotFoundException("Vendor", str(vendor_id))
|
||||
|
||||
deleted = creds_service.delete_credentials(vendor_id)
|
||||
if not deleted:
|
||||
raise ResourceNotFoundException(
|
||||
"LetzshopCredentials", str(vendor_id),
|
||||
message=f"Letzshop credentials not configured for vendor {vendor.name}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Admin {current_admin.email} deleted Letzshop credentials for vendor {vendor.name}"
|
||||
)
|
||||
|
||||
return LetzshopSuccessResponse(success=True, message="Letzshop credentials deleted")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Connection Testing
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.post(
|
||||
"/vendors/{vendor_id}/test",
|
||||
response_model=LetzshopConnectionTestResponse,
|
||||
)
|
||||
def test_vendor_connection(
|
||||
vendor_id: int = Path(..., description="Vendor ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Test the Letzshop connection for a vendor using stored credentials."""
|
||||
order_service = get_order_service(db)
|
||||
creds_service = get_credentials_service(db)
|
||||
|
||||
try:
|
||||
order_service.get_vendor_or_raise(vendor_id)
|
||||
except VendorNotFoundError:
|
||||
raise ResourceNotFoundException("Vendor", str(vendor_id))
|
||||
|
||||
success, response_time_ms, error = creds_service.test_connection(vendor_id)
|
||||
|
||||
return LetzshopConnectionTestResponse(
|
||||
success=success,
|
||||
message="Connection successful" if success else "Connection failed",
|
||||
response_time_ms=response_time_ms,
|
||||
error_details=error,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test", response_model=LetzshopConnectionTestResponse)
|
||||
def test_api_key(
|
||||
test_request: LetzshopConnectionTestRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Test a Letzshop API key without saving it."""
|
||||
creds_service = get_credentials_service(db)
|
||||
|
||||
success, response_time_ms, error = creds_service.test_api_key(
|
||||
api_key=test_request.api_key,
|
||||
api_endpoint=test_request.api_endpoint,
|
||||
)
|
||||
|
||||
return LetzshopConnectionTestResponse(
|
||||
success=success,
|
||||
message="Connection successful" if success else "Connection failed",
|
||||
response_time_ms=response_time_ms,
|
||||
error_details=error,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Order Management
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/vendors/{vendor_id}/orders",
|
||||
response_model=LetzshopOrderListResponse,
|
||||
)
|
||||
def list_vendor_letzshop_orders(
|
||||
vendor_id: int = Path(..., description="Vendor ID"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
sync_status: str | None = Query(None, description="Filter by sync status"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""List Letzshop orders for a vendor."""
|
||||
order_service = get_order_service(db)
|
||||
|
||||
try:
|
||||
order_service.get_vendor_or_raise(vendor_id)
|
||||
except VendorNotFoundError:
|
||||
raise ResourceNotFoundException("Vendor", str(vendor_id))
|
||||
|
||||
orders, total = order_service.list_orders(
|
||||
vendor_id=vendor_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
sync_status=sync_status,
|
||||
)
|
||||
|
||||
return LetzshopOrderListResponse(
|
||||
orders=[
|
||||
LetzshopOrderResponse(
|
||||
id=order.id,
|
||||
vendor_id=order.vendor_id,
|
||||
letzshop_order_id=order.letzshop_order_id,
|
||||
letzshop_shipment_id=order.letzshop_shipment_id,
|
||||
letzshop_order_number=order.letzshop_order_number,
|
||||
letzshop_state=order.letzshop_state,
|
||||
customer_email=order.customer_email,
|
||||
customer_name=order.customer_name,
|
||||
total_amount=order.total_amount,
|
||||
currency=order.currency,
|
||||
local_order_id=order.local_order_id,
|
||||
sync_status=order.sync_status,
|
||||
last_synced_at=order.last_synced_at,
|
||||
sync_error=order.sync_error,
|
||||
confirmed_at=order.confirmed_at,
|
||||
rejected_at=order.rejected_at,
|
||||
tracking_set_at=order.tracking_set_at,
|
||||
tracking_number=order.tracking_number,
|
||||
tracking_carrier=order.tracking_carrier,
|
||||
inventory_units=order.inventory_units,
|
||||
created_at=order.created_at,
|
||||
updated_at=order.updated_at,
|
||||
)
|
||||
for order in orders
|
||||
],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/vendors/{vendor_id}/sync",
|
||||
response_model=LetzshopSyncTriggerResponse,
|
||||
)
|
||||
def trigger_vendor_sync(
|
||||
vendor_id: int = Path(..., description="Vendor ID"),
|
||||
sync_request: LetzshopSyncTriggerRequest = LetzshopSyncTriggerRequest(),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Trigger a sync operation for a vendor.
|
||||
|
||||
This imports new orders from Letzshop.
|
||||
"""
|
||||
order_service = get_order_service(db)
|
||||
creds_service = get_credentials_service(db)
|
||||
|
||||
try:
|
||||
vendor = order_service.get_vendor_or_raise(vendor_id)
|
||||
except VendorNotFoundError:
|
||||
raise ResourceNotFoundException("Vendor", str(vendor_id))
|
||||
|
||||
# Verify credentials exist
|
||||
try:
|
||||
creds_service.get_credentials_or_raise(vendor_id)
|
||||
except CredentialsNotFoundError:
|
||||
raise ValidationException(
|
||||
f"Letzshop credentials not configured for vendor {vendor.name}"
|
||||
)
|
||||
|
||||
# Import orders using the client
|
||||
try:
|
||||
with creds_service.create_client(vendor_id) as client:
|
||||
shipments = client.get_unconfirmed_shipments()
|
||||
|
||||
orders_imported = 0
|
||||
orders_updated = 0
|
||||
errors = []
|
||||
|
||||
for shipment in shipments:
|
||||
try:
|
||||
# Check if order already exists
|
||||
existing = order_service.get_order_by_shipment_id(
|
||||
vendor_id, shipment["id"]
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Update existing order
|
||||
order_service.update_order_from_shipment(existing, shipment)
|
||||
orders_updated += 1
|
||||
else:
|
||||
# Create new order
|
||||
order_service.create_order(vendor_id, shipment)
|
||||
orders_imported += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Error processing shipment {shipment.get('id')}: {e}")
|
||||
|
||||
db.commit()
|
||||
|
||||
# Update sync status
|
||||
creds_service.update_sync_status(
|
||||
vendor_id,
|
||||
"success" if not errors else "partial",
|
||||
"; ".join(errors) if errors else None,
|
||||
)
|
||||
|
||||
return LetzshopSyncTriggerResponse(
|
||||
success=True,
|
||||
message=f"Sync completed: {orders_imported} imported, {orders_updated} updated",
|
||||
orders_imported=orders_imported,
|
||||
orders_updated=orders_updated,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
except LetzshopClientError as e:
|
||||
creds_service.update_sync_status(vendor_id, "failed", str(e))
|
||||
return LetzshopSyncTriggerResponse(
|
||||
success=False,
|
||||
message=f"Sync failed: {e}",
|
||||
errors=[str(e)],
|
||||
)
|
||||
2
app/api/v1/vendor/__init__.py
vendored
2
app/api/v1/vendor/__init__.py
vendored
@@ -21,6 +21,7 @@ from . import (
|
||||
dashboard,
|
||||
info,
|
||||
inventory,
|
||||
letzshop,
|
||||
marketplace,
|
||||
media,
|
||||
notifications,
|
||||
@@ -59,6 +60,7 @@ router.include_router(customers.router, tags=["vendor-customers"])
|
||||
router.include_router(team.router, tags=["vendor-team"])
|
||||
router.include_router(inventory.router, tags=["vendor-inventory"])
|
||||
router.include_router(marketplace.router, tags=["vendor-marketplace"])
|
||||
router.include_router(letzshop.router, tags=["vendor-letzshop"])
|
||||
|
||||
# Services (with prefixes: /payments/*, /media/*, etc.)
|
||||
router.include_router(payments.router, tags=["vendor-payments"])
|
||||
|
||||
689
app/api/v1/vendor/letzshop.py
vendored
Normal file
689
app/api/v1/vendor/letzshop.py
vendored
Normal file
@@ -0,0 +1,689 @@
|
||||
# app/api/v1/vendor/letzshop.py
|
||||
"""
|
||||
Vendor API endpoints for Letzshop marketplace integration.
|
||||
|
||||
Provides vendor-level management of:
|
||||
- Letzshop credentials
|
||||
- Connection testing
|
||||
- Order import and sync
|
||||
- Fulfillment operations (confirm, reject, tracking)
|
||||
|
||||
Vendor Context: Uses token_vendor_id from JWT token.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.exceptions import ResourceNotFoundException, ValidationException
|
||||
from app.services.letzshop import (
|
||||
CredentialsNotFoundError,
|
||||
LetzshopClientError,
|
||||
LetzshopCredentialsService,
|
||||
LetzshopOrderService,
|
||||
OrderNotFoundError,
|
||||
)
|
||||
from models.database.user import User
|
||||
from models.schema.letzshop import (
|
||||
FulfillmentConfirmRequest,
|
||||
FulfillmentOperationResponse,
|
||||
FulfillmentQueueItemResponse,
|
||||
FulfillmentQueueListResponse,
|
||||
FulfillmentRejectRequest,
|
||||
FulfillmentTrackingRequest,
|
||||
LetzshopConnectionTestRequest,
|
||||
LetzshopConnectionTestResponse,
|
||||
LetzshopCredentialsCreate,
|
||||
LetzshopCredentialsResponse,
|
||||
LetzshopCredentialsStatus,
|
||||
LetzshopCredentialsUpdate,
|
||||
LetzshopOrderDetailResponse,
|
||||
LetzshopOrderListResponse,
|
||||
LetzshopOrderResponse,
|
||||
LetzshopSuccessResponse,
|
||||
LetzshopSyncLogListResponse,
|
||||
LetzshopSyncLogResponse,
|
||||
LetzshopSyncTriggerRequest,
|
||||
LetzshopSyncTriggerResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/letzshop")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_order_service(db: Session) -> LetzshopOrderService:
|
||||
"""Get order service instance."""
|
||||
return LetzshopOrderService(db)
|
||||
|
||||
|
||||
def get_credentials_service(db: Session) -> LetzshopCredentialsService:
|
||||
"""Get credentials service instance."""
|
||||
return LetzshopCredentialsService(db)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Status & Configuration
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/status", response_model=LetzshopCredentialsStatus)
|
||||
def get_letzshop_status(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get Letzshop integration status for the current vendor."""
|
||||
creds_service = get_credentials_service(db)
|
||||
status = creds_service.get_status(current_user.token_vendor_id)
|
||||
return LetzshopCredentialsStatus(**status)
|
||||
|
||||
|
||||
@router.get("/credentials", response_model=LetzshopCredentialsResponse)
|
||||
def get_credentials(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get Letzshop credentials for the current vendor (API key is masked)."""
|
||||
creds_service = get_credentials_service(db)
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
try:
|
||||
credentials = creds_service.get_credentials_or_raise(vendor_id)
|
||||
except CredentialsNotFoundError:
|
||||
raise ResourceNotFoundException("LetzshopCredentials", str(vendor_id))
|
||||
|
||||
return LetzshopCredentialsResponse(
|
||||
id=credentials.id,
|
||||
vendor_id=credentials.vendor_id,
|
||||
api_key_masked=creds_service.get_masked_api_key(vendor_id),
|
||||
api_endpoint=credentials.api_endpoint,
|
||||
auto_sync_enabled=credentials.auto_sync_enabled,
|
||||
sync_interval_minutes=credentials.sync_interval_minutes,
|
||||
last_sync_at=credentials.last_sync_at,
|
||||
last_sync_status=credentials.last_sync_status,
|
||||
last_sync_error=credentials.last_sync_error,
|
||||
created_at=credentials.created_at,
|
||||
updated_at=credentials.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/credentials", response_model=LetzshopCredentialsResponse)
|
||||
def save_credentials(
|
||||
credentials_data: LetzshopCredentialsCreate,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create or update Letzshop credentials for the current vendor."""
|
||||
creds_service = get_credentials_service(db)
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
credentials = creds_service.upsert_credentials(
|
||||
vendor_id=vendor_id,
|
||||
api_key=credentials_data.api_key,
|
||||
api_endpoint=credentials_data.api_endpoint,
|
||||
auto_sync_enabled=credentials_data.auto_sync_enabled,
|
||||
sync_interval_minutes=credentials_data.sync_interval_minutes,
|
||||
)
|
||||
|
||||
logger.info(f"Vendor user {current_user.email} updated Letzshop credentials")
|
||||
|
||||
return LetzshopCredentialsResponse(
|
||||
id=credentials.id,
|
||||
vendor_id=credentials.vendor_id,
|
||||
api_key_masked=creds_service.get_masked_api_key(vendor_id),
|
||||
api_endpoint=credentials.api_endpoint,
|
||||
auto_sync_enabled=credentials.auto_sync_enabled,
|
||||
sync_interval_minutes=credentials.sync_interval_minutes,
|
||||
last_sync_at=credentials.last_sync_at,
|
||||
last_sync_status=credentials.last_sync_status,
|
||||
last_sync_error=credentials.last_sync_error,
|
||||
created_at=credentials.created_at,
|
||||
updated_at=credentials.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/credentials", response_model=LetzshopCredentialsResponse)
|
||||
def update_credentials(
|
||||
credentials_data: LetzshopCredentialsUpdate,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Partially update Letzshop credentials for the current vendor."""
|
||||
creds_service = get_credentials_service(db)
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
try:
|
||||
credentials = creds_service.update_credentials(
|
||||
vendor_id=vendor_id,
|
||||
api_key=credentials_data.api_key,
|
||||
api_endpoint=credentials_data.api_endpoint,
|
||||
auto_sync_enabled=credentials_data.auto_sync_enabled,
|
||||
sync_interval_minutes=credentials_data.sync_interval_minutes,
|
||||
)
|
||||
except CredentialsNotFoundError:
|
||||
raise ResourceNotFoundException("LetzshopCredentials", str(vendor_id))
|
||||
|
||||
return LetzshopCredentialsResponse(
|
||||
id=credentials.id,
|
||||
vendor_id=credentials.vendor_id,
|
||||
api_key_masked=creds_service.get_masked_api_key(vendor_id),
|
||||
api_endpoint=credentials.api_endpoint,
|
||||
auto_sync_enabled=credentials.auto_sync_enabled,
|
||||
sync_interval_minutes=credentials.sync_interval_minutes,
|
||||
last_sync_at=credentials.last_sync_at,
|
||||
last_sync_status=credentials.last_sync_status,
|
||||
last_sync_error=credentials.last_sync_error,
|
||||
created_at=credentials.created_at,
|
||||
updated_at=credentials.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/credentials", response_model=LetzshopSuccessResponse)
|
||||
def delete_credentials(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Delete Letzshop credentials for the current vendor."""
|
||||
creds_service = get_credentials_service(db)
|
||||
|
||||
deleted = creds_service.delete_credentials(current_user.token_vendor_id)
|
||||
if not deleted:
|
||||
raise ResourceNotFoundException(
|
||||
"LetzshopCredentials", str(current_user.token_vendor_id)
|
||||
)
|
||||
|
||||
logger.info(f"Vendor user {current_user.email} deleted Letzshop credentials")
|
||||
return LetzshopSuccessResponse(success=True, message="Letzshop credentials deleted")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Connection Testing
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.post("/test", response_model=LetzshopConnectionTestResponse)
|
||||
def test_connection(
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Test the Letzshop connection using stored credentials."""
|
||||
creds_service = get_credentials_service(db)
|
||||
|
||||
success, response_time_ms, error = creds_service.test_connection(
|
||||
current_user.token_vendor_id
|
||||
)
|
||||
|
||||
return LetzshopConnectionTestResponse(
|
||||
success=success,
|
||||
message="Connection successful" if success else "Connection failed",
|
||||
response_time_ms=response_time_ms,
|
||||
error_details=error,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test-key", response_model=LetzshopConnectionTestResponse)
|
||||
def test_api_key(
|
||||
test_request: LetzshopConnectionTestRequest,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Test a Letzshop API key without saving it."""
|
||||
creds_service = get_credentials_service(db)
|
||||
|
||||
success, response_time_ms, error = creds_service.test_api_key(
|
||||
api_key=test_request.api_key,
|
||||
api_endpoint=test_request.api_endpoint,
|
||||
)
|
||||
|
||||
return LetzshopConnectionTestResponse(
|
||||
success=success,
|
||||
message="Connection successful" if success else "Connection failed",
|
||||
response_time_ms=response_time_ms,
|
||||
error_details=error,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Order Management
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/orders", response_model=LetzshopOrderListResponse)
|
||||
def list_orders(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
sync_status: str | None = Query(None, description="Filter by sync status"),
|
||||
letzshop_state: str | None = Query(None, description="Filter by Letzshop state"),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List Letzshop orders for the current vendor."""
|
||||
order_service = get_order_service(db)
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
orders, total = order_service.list_orders(
|
||||
vendor_id=vendor_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
sync_status=sync_status,
|
||||
letzshop_state=letzshop_state,
|
||||
)
|
||||
|
||||
return LetzshopOrderListResponse(
|
||||
orders=[
|
||||
LetzshopOrderResponse(
|
||||
id=order.id,
|
||||
vendor_id=order.vendor_id,
|
||||
letzshop_order_id=order.letzshop_order_id,
|
||||
letzshop_shipment_id=order.letzshop_shipment_id,
|
||||
letzshop_order_number=order.letzshop_order_number,
|
||||
letzshop_state=order.letzshop_state,
|
||||
customer_email=order.customer_email,
|
||||
customer_name=order.customer_name,
|
||||
total_amount=order.total_amount,
|
||||
currency=order.currency,
|
||||
local_order_id=order.local_order_id,
|
||||
sync_status=order.sync_status,
|
||||
last_synced_at=order.last_synced_at,
|
||||
sync_error=order.sync_error,
|
||||
confirmed_at=order.confirmed_at,
|
||||
rejected_at=order.rejected_at,
|
||||
tracking_set_at=order.tracking_set_at,
|
||||
tracking_number=order.tracking_number,
|
||||
tracking_carrier=order.tracking_carrier,
|
||||
inventory_units=order.inventory_units,
|
||||
created_at=order.created_at,
|
||||
updated_at=order.updated_at,
|
||||
)
|
||||
for order in orders
|
||||
],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/orders/{order_id}", response_model=LetzshopOrderDetailResponse)
|
||||
def get_order(
|
||||
order_id: int = Path(..., description="Order ID"),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get a specific Letzshop order with full details."""
|
||||
order_service = get_order_service(db)
|
||||
|
||||
try:
|
||||
order = order_service.get_order_or_raise(
|
||||
current_user.token_vendor_id, order_id
|
||||
)
|
||||
except OrderNotFoundError:
|
||||
raise ResourceNotFoundException("LetzshopOrder", str(order_id))
|
||||
|
||||
return LetzshopOrderDetailResponse(
|
||||
id=order.id,
|
||||
vendor_id=order.vendor_id,
|
||||
letzshop_order_id=order.letzshop_order_id,
|
||||
letzshop_shipment_id=order.letzshop_shipment_id,
|
||||
letzshop_order_number=order.letzshop_order_number,
|
||||
letzshop_state=order.letzshop_state,
|
||||
customer_email=order.customer_email,
|
||||
customer_name=order.customer_name,
|
||||
total_amount=order.total_amount,
|
||||
currency=order.currency,
|
||||
local_order_id=order.local_order_id,
|
||||
sync_status=order.sync_status,
|
||||
last_synced_at=order.last_synced_at,
|
||||
sync_error=order.sync_error,
|
||||
confirmed_at=order.confirmed_at,
|
||||
rejected_at=order.rejected_at,
|
||||
tracking_set_at=order.tracking_set_at,
|
||||
tracking_number=order.tracking_number,
|
||||
tracking_carrier=order.tracking_carrier,
|
||||
inventory_units=order.inventory_units,
|
||||
raw_order_data=order.raw_order_data,
|
||||
created_at=order.created_at,
|
||||
updated_at=order.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/orders/import", response_model=LetzshopSyncTriggerResponse)
|
||||
def import_orders(
|
||||
sync_request: LetzshopSyncTriggerRequest = LetzshopSyncTriggerRequest(),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Import new orders from Letzshop."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
order_service = get_order_service(db)
|
||||
creds_service = get_credentials_service(db)
|
||||
|
||||
# Verify credentials exist
|
||||
try:
|
||||
creds_service.get_credentials_or_raise(vendor_id)
|
||||
except CredentialsNotFoundError:
|
||||
raise ValidationException("Letzshop credentials not configured")
|
||||
|
||||
# Import orders
|
||||
try:
|
||||
with creds_service.create_client(vendor_id) as client:
|
||||
shipments = client.get_unconfirmed_shipments()
|
||||
|
||||
orders_imported = 0
|
||||
orders_updated = 0
|
||||
errors = []
|
||||
|
||||
for shipment in shipments:
|
||||
try:
|
||||
existing = order_service.get_order_by_shipment_id(
|
||||
vendor_id, shipment["id"]
|
||||
)
|
||||
|
||||
if existing:
|
||||
order_service.update_order_from_shipment(existing, shipment)
|
||||
orders_updated += 1
|
||||
else:
|
||||
order_service.create_order(vendor_id, shipment)
|
||||
orders_imported += 1
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Error processing shipment {shipment.get('id')}: {e}")
|
||||
|
||||
db.commit()
|
||||
creds_service.update_sync_status(
|
||||
vendor_id,
|
||||
"success" if not errors else "partial",
|
||||
"; ".join(errors) if errors else None,
|
||||
)
|
||||
|
||||
return LetzshopSyncTriggerResponse(
|
||||
success=True,
|
||||
message=f"Import completed: {orders_imported} imported, {orders_updated} updated",
|
||||
orders_imported=orders_imported,
|
||||
orders_updated=orders_updated,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
except LetzshopClientError as e:
|
||||
creds_service.update_sync_status(vendor_id, "failed", str(e))
|
||||
return LetzshopSyncTriggerResponse(
|
||||
success=False,
|
||||
message=f"Import failed: {e}",
|
||||
errors=[str(e)],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fulfillment Operations
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/confirm", response_model=FulfillmentOperationResponse)
|
||||
def confirm_order(
|
||||
order_id: int = Path(..., description="Order ID"),
|
||||
confirm_request: FulfillmentConfirmRequest | None = None,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Confirm inventory units for a Letzshop order."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
order_service = get_order_service(db)
|
||||
creds_service = get_credentials_service(db)
|
||||
|
||||
try:
|
||||
order = order_service.get_order_or_raise(vendor_id, order_id)
|
||||
except OrderNotFoundError:
|
||||
raise ResourceNotFoundException("LetzshopOrder", str(order_id))
|
||||
|
||||
# Get inventory unit IDs from request or order
|
||||
if confirm_request and confirm_request.inventory_unit_ids:
|
||||
inventory_unit_ids = confirm_request.inventory_unit_ids
|
||||
elif order.inventory_units:
|
||||
inventory_unit_ids = [u["id"] for u in order.inventory_units]
|
||||
else:
|
||||
raise ValidationException("No inventory units to confirm")
|
||||
|
||||
try:
|
||||
with creds_service.create_client(vendor_id) as client:
|
||||
result = client.confirm_inventory_units(inventory_unit_ids)
|
||||
|
||||
# Check for errors
|
||||
if result.get("errors"):
|
||||
error_messages = [
|
||||
f"{e.get('id', 'unknown')}: {e.get('message', 'Unknown error')}"
|
||||
for e in result["errors"]
|
||||
]
|
||||
return FulfillmentOperationResponse(
|
||||
success=False,
|
||||
message="Some inventory units could not be confirmed",
|
||||
errors=error_messages,
|
||||
)
|
||||
|
||||
# Update order status
|
||||
order_service.mark_order_confirmed(order)
|
||||
db.commit()
|
||||
|
||||
return FulfillmentOperationResponse(
|
||||
success=True,
|
||||
message=f"Confirmed {len(inventory_unit_ids)} inventory units",
|
||||
confirmed_units=[
|
||||
u.get("id") for u in result.get("inventoryUnits", [])
|
||||
],
|
||||
)
|
||||
|
||||
except LetzshopClientError as e:
|
||||
return FulfillmentOperationResponse(success=False, message=str(e))
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/reject", response_model=FulfillmentOperationResponse)
|
||||
def reject_order(
|
||||
order_id: int = Path(..., description="Order ID"),
|
||||
reject_request: FulfillmentRejectRequest | None = None,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Reject inventory units for a Letzshop order."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
order_service = get_order_service(db)
|
||||
creds_service = get_credentials_service(db)
|
||||
|
||||
try:
|
||||
order = order_service.get_order_or_raise(vendor_id, order_id)
|
||||
except OrderNotFoundError:
|
||||
raise ResourceNotFoundException("LetzshopOrder", str(order_id))
|
||||
|
||||
# Get inventory unit IDs from request or order
|
||||
if reject_request and reject_request.inventory_unit_ids:
|
||||
inventory_unit_ids = reject_request.inventory_unit_ids
|
||||
elif order.inventory_units:
|
||||
inventory_unit_ids = [u["id"] for u in order.inventory_units]
|
||||
else:
|
||||
raise ValidationException("No inventory units to reject")
|
||||
|
||||
try:
|
||||
with creds_service.create_client(vendor_id) as client:
|
||||
result = client.reject_inventory_units(inventory_unit_ids)
|
||||
|
||||
if result.get("errors"):
|
||||
error_messages = [
|
||||
f"{e.get('id', 'unknown')}: {e.get('message', 'Unknown error')}"
|
||||
for e in result["errors"]
|
||||
]
|
||||
return FulfillmentOperationResponse(
|
||||
success=False,
|
||||
message="Some inventory units could not be rejected",
|
||||
errors=error_messages,
|
||||
)
|
||||
|
||||
order_service.mark_order_rejected(order)
|
||||
db.commit()
|
||||
|
||||
return FulfillmentOperationResponse(
|
||||
success=True,
|
||||
message=f"Rejected {len(inventory_unit_ids)} inventory units",
|
||||
)
|
||||
|
||||
except LetzshopClientError as e:
|
||||
return FulfillmentOperationResponse(success=False, message=str(e))
|
||||
|
||||
|
||||
@router.post("/orders/{order_id}/tracking", response_model=FulfillmentOperationResponse)
|
||||
def set_order_tracking(
|
||||
order_id: int = Path(..., description="Order ID"),
|
||||
tracking_request: FulfillmentTrackingRequest = ...,
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Set tracking information for a Letzshop order."""
|
||||
vendor_id = current_user.token_vendor_id
|
||||
order_service = get_order_service(db)
|
||||
creds_service = get_credentials_service(db)
|
||||
|
||||
try:
|
||||
order = order_service.get_order_or_raise(vendor_id, order_id)
|
||||
except OrderNotFoundError:
|
||||
raise ResourceNotFoundException("LetzshopOrder", str(order_id))
|
||||
|
||||
if not order.letzshop_shipment_id:
|
||||
raise ValidationException("Order does not have a shipment ID")
|
||||
|
||||
try:
|
||||
with creds_service.create_client(vendor_id) as client:
|
||||
result = client.set_shipment_tracking(
|
||||
shipment_id=order.letzshop_shipment_id,
|
||||
tracking_code=tracking_request.tracking_number,
|
||||
tracking_provider=tracking_request.tracking_carrier,
|
||||
)
|
||||
|
||||
if result.get("errors"):
|
||||
error_messages = [
|
||||
f"{e.get('code', 'unknown')}: {e.get('message', 'Unknown error')}"
|
||||
for e in result["errors"]
|
||||
]
|
||||
return FulfillmentOperationResponse(
|
||||
success=False,
|
||||
message="Failed to set tracking",
|
||||
errors=error_messages,
|
||||
)
|
||||
|
||||
order_service.set_order_tracking(
|
||||
order,
|
||||
tracking_request.tracking_number,
|
||||
tracking_request.tracking_carrier,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return FulfillmentOperationResponse(
|
||||
success=True,
|
||||
message="Tracking information set",
|
||||
tracking_number=tracking_request.tracking_number,
|
||||
tracking_carrier=tracking_request.tracking_carrier,
|
||||
)
|
||||
|
||||
except LetzshopClientError as e:
|
||||
return FulfillmentOperationResponse(success=False, message=str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Sync Logs
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/logs", response_model=LetzshopSyncLogListResponse)
|
||||
def list_sync_logs(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List Letzshop sync logs for the current vendor."""
|
||||
order_service = get_order_service(db)
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
logs, total = order_service.list_sync_logs(
|
||||
vendor_id=vendor_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
return LetzshopSyncLogListResponse(
|
||||
logs=[
|
||||
LetzshopSyncLogResponse(
|
||||
id=log.id,
|
||||
vendor_id=log.vendor_id,
|
||||
operation_type=log.operation_type,
|
||||
direction=log.direction,
|
||||
status=log.status,
|
||||
records_processed=log.records_processed,
|
||||
records_succeeded=log.records_succeeded,
|
||||
records_failed=log.records_failed,
|
||||
error_details=log.error_details,
|
||||
started_at=log.started_at,
|
||||
completed_at=log.completed_at,
|
||||
duration_seconds=log.duration_seconds,
|
||||
triggered_by=log.triggered_by,
|
||||
created_at=log.created_at,
|
||||
)
|
||||
for log in logs
|
||||
],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fulfillment Queue
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/queue", response_model=FulfillmentQueueListResponse)
|
||||
def list_fulfillment_queue(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
status: str | None = Query(None, description="Filter by status"),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List fulfillment queue items for the current vendor."""
|
||||
order_service = get_order_service(db)
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
items, total = order_service.list_fulfillment_queue(
|
||||
vendor_id=vendor_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
status=status,
|
||||
)
|
||||
|
||||
return FulfillmentQueueListResponse(
|
||||
items=[
|
||||
FulfillmentQueueItemResponse(
|
||||
id=item.id,
|
||||
vendor_id=item.vendor_id,
|
||||
letzshop_order_id=item.letzshop_order_id,
|
||||
operation=item.operation,
|
||||
payload=item.payload,
|
||||
status=item.status,
|
||||
attempts=item.attempts,
|
||||
max_attempts=item.max_attempts,
|
||||
last_attempt_at=item.last_attempt_at,
|
||||
next_retry_at=item.next_retry_at,
|
||||
error_message=item.error_message,
|
||||
completed_at=item.completed_at,
|
||||
response_data=item.response_data,
|
||||
created_at=item.created_at,
|
||||
updated_at=item.updated_at,
|
||||
)
|
||||
for item in items
|
||||
],
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
45
app/services/letzshop/__init__.py
Normal file
45
app/services/letzshop/__init__.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# app/services/letzshop/__init__.py
|
||||
"""
|
||||
Letzshop marketplace integration services.
|
||||
|
||||
Provides:
|
||||
- GraphQL client for API communication
|
||||
- Credential management service
|
||||
- Order import service
|
||||
- Fulfillment sync service
|
||||
"""
|
||||
|
||||
from .client import (
|
||||
LetzshopAPIError,
|
||||
LetzshopAuthError,
|
||||
LetzshopClient,
|
||||
LetzshopClientError,
|
||||
LetzshopConnectionError,
|
||||
)
|
||||
from .credentials import (
|
||||
CredentialsError,
|
||||
CredentialsNotFoundError,
|
||||
LetzshopCredentialsService,
|
||||
)
|
||||
from .order_service import (
|
||||
LetzshopOrderService,
|
||||
OrderNotFoundError,
|
||||
VendorNotFoundError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Client
|
||||
"LetzshopClient",
|
||||
"LetzshopClientError",
|
||||
"LetzshopAuthError",
|
||||
"LetzshopAPIError",
|
||||
"LetzshopConnectionError",
|
||||
# Credentials
|
||||
"LetzshopCredentialsService",
|
||||
"CredentialsError",
|
||||
"CredentialsNotFoundError",
|
||||
# Order Service
|
||||
"LetzshopOrderService",
|
||||
"OrderNotFoundError",
|
||||
"VendorNotFoundError",
|
||||
]
|
||||
493
app/services/letzshop/client.py
Normal file
493
app/services/letzshop/client.py
Normal file
@@ -0,0 +1,493 @@
|
||||
# app/services/letzshop/client.py
|
||||
"""
|
||||
GraphQL client for Letzshop marketplace API.
|
||||
|
||||
Handles authentication, request formatting, and error handling
|
||||
for all Letzshop API operations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default API endpoint
|
||||
DEFAULT_ENDPOINT = "https://letzshop.lu/graphql"
|
||||
|
||||
|
||||
class LetzshopClientError(Exception):
|
||||
"""Base exception for Letzshop client errors."""
|
||||
|
||||
def __init__(self, message: str, response_data: dict | None = None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.response_data = response_data
|
||||
|
||||
|
||||
class LetzshopAuthError(LetzshopClientError):
|
||||
"""Raised when authentication fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class LetzshopAPIError(LetzshopClientError):
|
||||
"""Raised when the API returns an error response."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class LetzshopConnectionError(LetzshopClientError):
|
||||
"""Raised when connection to the API fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GraphQL Queries
|
||||
# ============================================================================
|
||||
|
||||
QUERY_SHIPMENTS = """
|
||||
query GetShipments($state: ShipmentState) {
|
||||
shipments(state: $state) {
|
||||
nodes {
|
||||
id
|
||||
number
|
||||
state
|
||||
createdAt
|
||||
updatedAt
|
||||
order {
|
||||
id
|
||||
number
|
||||
email
|
||||
totalPrice {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
lineItems {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
quantity
|
||||
price {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
}
|
||||
}
|
||||
shippingAddress {
|
||||
firstName
|
||||
lastName
|
||||
company
|
||||
address1
|
||||
address2
|
||||
city
|
||||
zip
|
||||
country
|
||||
}
|
||||
billingAddress {
|
||||
firstName
|
||||
lastName
|
||||
company
|
||||
address1
|
||||
address2
|
||||
city
|
||||
zip
|
||||
country
|
||||
}
|
||||
}
|
||||
inventoryUnits {
|
||||
nodes {
|
||||
id
|
||||
state
|
||||
variant {
|
||||
id
|
||||
sku
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
tracking {
|
||||
code
|
||||
provider
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
QUERY_SHIPMENT_BY_ID = """
|
||||
query GetShipment($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on Shipment {
|
||||
id
|
||||
number
|
||||
state
|
||||
createdAt
|
||||
updatedAt
|
||||
order {
|
||||
id
|
||||
number
|
||||
email
|
||||
totalPrice {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
}
|
||||
inventoryUnits {
|
||||
nodes {
|
||||
id
|
||||
state
|
||||
variant {
|
||||
id
|
||||
sku
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
tracking {
|
||||
code
|
||||
provider
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
# ============================================================================
|
||||
# GraphQL Mutations
|
||||
# ============================================================================
|
||||
|
||||
MUTATION_CONFIRM_INVENTORY_UNITS = """
|
||||
mutation ConfirmInventoryUnits($input: ConfirmInventoryUnitsInput!) {
|
||||
confirmInventoryUnits(input: $input) {
|
||||
inventoryUnits {
|
||||
id
|
||||
state
|
||||
}
|
||||
errors {
|
||||
id
|
||||
code
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
MUTATION_REJECT_INVENTORY_UNITS = """
|
||||
mutation RejectInventoryUnits($input: RejectInventoryUnitsInput!) {
|
||||
returnInventoryUnits(input: $input) {
|
||||
inventoryUnits {
|
||||
id
|
||||
state
|
||||
}
|
||||
errors {
|
||||
id
|
||||
code
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
MUTATION_SET_SHIPMENT_TRACKING = """
|
||||
mutation SetShipmentTracking($input: SetShipmentTrackingInput!) {
|
||||
setShipmentTracking(input: $input) {
|
||||
shipment {
|
||||
id
|
||||
tracking {
|
||||
code
|
||||
provider
|
||||
}
|
||||
}
|
||||
errors {
|
||||
code
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class LetzshopClient:
|
||||
"""
|
||||
GraphQL client for Letzshop marketplace API.
|
||||
|
||||
Usage:
|
||||
client = LetzshopClient(api_key="your-api-key")
|
||||
shipments = client.get_shipments(state="unconfirmed")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
endpoint: str = DEFAULT_ENDPOINT,
|
||||
timeout: int = 30,
|
||||
):
|
||||
"""
|
||||
Initialize the Letzshop client.
|
||||
|
||||
Args:
|
||||
api_key: The Letzshop API key (Bearer token).
|
||||
endpoint: The GraphQL endpoint URL.
|
||||
timeout: Request timeout in seconds.
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.endpoint = endpoint
|
||||
self.timeout = timeout
|
||||
self._session: requests.Session | None = None
|
||||
|
||||
@property
|
||||
def session(self) -> requests.Session:
|
||||
"""Get or create a requests session."""
|
||||
if self._session is None:
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update(
|
||||
{
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
return self._session
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the HTTP session."""
|
||||
if self._session is not None:
|
||||
self._session.close()
|
||||
self._session = None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
return False
|
||||
|
||||
def _execute(
|
||||
self,
|
||||
query: str,
|
||||
variables: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Execute a GraphQL query or mutation.
|
||||
|
||||
Args:
|
||||
query: The GraphQL query or mutation string.
|
||||
variables: Optional variables for the query.
|
||||
|
||||
Returns:
|
||||
The response data from the API.
|
||||
|
||||
Raises:
|
||||
LetzshopAuthError: If authentication fails.
|
||||
LetzshopAPIError: If the API returns an error.
|
||||
LetzshopConnectionError: If the request fails.
|
||||
"""
|
||||
payload = {"query": query}
|
||||
if variables:
|
||||
payload["variables"] = variables
|
||||
|
||||
logger.debug(f"Executing GraphQL request to {self.endpoint}")
|
||||
|
||||
try:
|
||||
response = self.session.post(
|
||||
self.endpoint,
|
||||
json=payload,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
except requests.exceptions.Timeout as e:
|
||||
raise LetzshopConnectionError(f"Request timed out: {e}") from e
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise LetzshopConnectionError(f"Connection failed: {e}") from e
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise LetzshopConnectionError(f"Request failed: {e}") from e
|
||||
|
||||
# Handle HTTP-level errors
|
||||
if response.status_code == 401:
|
||||
raise LetzshopAuthError(
|
||||
"Authentication failed. Please check your API key.",
|
||||
response_data={"status_code": 401},
|
||||
)
|
||||
if response.status_code == 403:
|
||||
raise LetzshopAuthError(
|
||||
"Access forbidden. Your API key may not have the required permissions.",
|
||||
response_data={"status_code": 403},
|
||||
)
|
||||
if response.status_code >= 500:
|
||||
raise LetzshopAPIError(
|
||||
f"Letzshop server error (HTTP {response.status_code})",
|
||||
response_data={"status_code": response.status_code},
|
||||
)
|
||||
|
||||
# Parse JSON response
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError as e:
|
||||
raise LetzshopAPIError(
|
||||
f"Invalid JSON response: {response.text[:200]}"
|
||||
) from e
|
||||
|
||||
# Check for GraphQL errors
|
||||
if "errors" in data and data["errors"]:
|
||||
error_messages = [
|
||||
err.get("message", "Unknown error") for err in data["errors"]
|
||||
]
|
||||
raise LetzshopAPIError(
|
||||
f"GraphQL errors: {'; '.join(error_messages)}",
|
||||
response_data=data,
|
||||
)
|
||||
|
||||
return data.get("data", {})
|
||||
|
||||
# ========================================================================
|
||||
# Connection Testing
|
||||
# ========================================================================
|
||||
|
||||
def test_connection(self) -> tuple[bool, float, str | None]:
|
||||
"""
|
||||
Test the connection to Letzshop API.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, response_time_ms, error_message).
|
||||
"""
|
||||
test_query = """
|
||||
query TestConnection {
|
||||
__typename
|
||||
}
|
||||
"""
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
self._execute(test_query)
|
||||
elapsed_ms = (time.time() - start_time) * 1000
|
||||
return True, elapsed_ms, None
|
||||
except LetzshopClientError as e:
|
||||
elapsed_ms = (time.time() - start_time) * 1000
|
||||
return False, elapsed_ms, str(e)
|
||||
|
||||
# ========================================================================
|
||||
# Shipment Queries
|
||||
# ========================================================================
|
||||
|
||||
def get_shipments(
|
||||
self,
|
||||
state: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get shipments from Letzshop.
|
||||
|
||||
Args:
|
||||
state: Optional state filter (e.g., "unconfirmed", "confirmed").
|
||||
|
||||
Returns:
|
||||
List of shipment data dictionaries.
|
||||
"""
|
||||
variables = {}
|
||||
if state:
|
||||
variables["state"] = state
|
||||
|
||||
data = self._execute(QUERY_SHIPMENTS, variables)
|
||||
shipments_data = data.get("shipments", {})
|
||||
return shipments_data.get("nodes", [])
|
||||
|
||||
def get_unconfirmed_shipments(self) -> list[dict[str, Any]]:
|
||||
"""Get all unconfirmed shipments."""
|
||||
return self.get_shipments(state="unconfirmed")
|
||||
|
||||
def get_shipment_by_id(self, shipment_id: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Get a single shipment by its ID.
|
||||
|
||||
Args:
|
||||
shipment_id: The Letzshop shipment ID.
|
||||
|
||||
Returns:
|
||||
Shipment data or None if not found.
|
||||
"""
|
||||
data = self._execute(QUERY_SHIPMENT_BY_ID, {"id": shipment_id})
|
||||
return data.get("node")
|
||||
|
||||
# ========================================================================
|
||||
# Fulfillment Mutations
|
||||
# ========================================================================
|
||||
|
||||
def confirm_inventory_units(
|
||||
self,
|
||||
inventory_unit_ids: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Confirm inventory units for fulfillment.
|
||||
|
||||
Args:
|
||||
inventory_unit_ids: List of inventory unit IDs to confirm.
|
||||
|
||||
Returns:
|
||||
Response data including confirmed units and any errors.
|
||||
"""
|
||||
variables = {
|
||||
"input": {
|
||||
"inventoryUnitIds": inventory_unit_ids,
|
||||
}
|
||||
}
|
||||
|
||||
data = self._execute(MUTATION_CONFIRM_INVENTORY_UNITS, variables)
|
||||
return data.get("confirmInventoryUnits", {})
|
||||
|
||||
def reject_inventory_units(
|
||||
self,
|
||||
inventory_unit_ids: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Reject/return inventory units.
|
||||
|
||||
Args:
|
||||
inventory_unit_ids: List of inventory unit IDs to reject.
|
||||
|
||||
Returns:
|
||||
Response data including rejected units and any errors.
|
||||
"""
|
||||
variables = {
|
||||
"input": {
|
||||
"inventoryUnitIds": inventory_unit_ids,
|
||||
}
|
||||
}
|
||||
|
||||
data = self._execute(MUTATION_REJECT_INVENTORY_UNITS, variables)
|
||||
return data.get("returnInventoryUnits", {})
|
||||
|
||||
def set_shipment_tracking(
|
||||
self,
|
||||
shipment_id: str,
|
||||
tracking_code: str,
|
||||
tracking_provider: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Set tracking information for a shipment.
|
||||
|
||||
Args:
|
||||
shipment_id: The Letzshop shipment ID.
|
||||
tracking_code: The tracking number.
|
||||
tracking_provider: The carrier code (e.g., "dhl", "ups").
|
||||
|
||||
Returns:
|
||||
Response data including updated shipment and any errors.
|
||||
"""
|
||||
variables = {
|
||||
"input": {
|
||||
"shipmentId": shipment_id,
|
||||
"tracking": {
|
||||
"code": tracking_code,
|
||||
"provider": tracking_provider,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
data = self._execute(MUTATION_SET_SHIPMENT_TRACKING, variables)
|
||||
return data.get("setShipmentTracking", {})
|
||||
413
app/services/letzshop/credentials.py
Normal file
413
app/services/letzshop/credentials.py
Normal file
@@ -0,0 +1,413 @@
|
||||
# app/services/letzshop/credentials.py
|
||||
"""
|
||||
Letzshop credentials management service.
|
||||
|
||||
Handles secure storage and retrieval of per-vendor Letzshop API credentials.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.utils.encryption import decrypt_value, encrypt_value, mask_api_key
|
||||
from models.database.letzshop import VendorLetzshopCredentials
|
||||
|
||||
from .client import LetzshopClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default Letzshop GraphQL endpoint
|
||||
DEFAULT_ENDPOINT = "https://letzshop.lu/graphql"
|
||||
|
||||
|
||||
class CredentialsError(Exception):
|
||||
"""Base exception for credentials errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class CredentialsNotFoundError(CredentialsError):
|
||||
"""Raised when credentials are not found for a vendor."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class LetzshopCredentialsService:
|
||||
"""
|
||||
Service for managing Letzshop API credentials.
|
||||
|
||||
Provides secure storage and retrieval of encrypted API keys,
|
||||
connection testing, and sync status updates.
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
"""
|
||||
Initialize the credentials service.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy database session.
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
# ========================================================================
|
||||
# CRUD Operations
|
||||
# ========================================================================
|
||||
|
||||
def get_credentials(
|
||||
self, vendor_id: int
|
||||
) -> VendorLetzshopCredentials | None:
|
||||
"""
|
||||
Get Letzshop credentials for a vendor.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
|
||||
Returns:
|
||||
VendorLetzshopCredentials or None if not found.
|
||||
"""
|
||||
return (
|
||||
self.db.query(VendorLetzshopCredentials)
|
||||
.filter(VendorLetzshopCredentials.vendor_id == vendor_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_credentials_or_raise(
|
||||
self, vendor_id: int
|
||||
) -> VendorLetzshopCredentials:
|
||||
"""
|
||||
Get Letzshop credentials for a vendor or raise an exception.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
|
||||
Returns:
|
||||
VendorLetzshopCredentials.
|
||||
|
||||
Raises:
|
||||
CredentialsNotFoundError: If credentials are not found.
|
||||
"""
|
||||
credentials = self.get_credentials(vendor_id)
|
||||
if credentials is None:
|
||||
raise CredentialsNotFoundError(
|
||||
f"Letzshop credentials not found for vendor {vendor_id}"
|
||||
)
|
||||
return credentials
|
||||
|
||||
def create_credentials(
|
||||
self,
|
||||
vendor_id: int,
|
||||
api_key: str,
|
||||
api_endpoint: str | None = None,
|
||||
auto_sync_enabled: bool = False,
|
||||
sync_interval_minutes: int = 15,
|
||||
) -> VendorLetzshopCredentials:
|
||||
"""
|
||||
Create Letzshop credentials for a vendor.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
api_key: The Letzshop API key (will be encrypted).
|
||||
api_endpoint: Custom API endpoint (optional).
|
||||
auto_sync_enabled: Whether to enable automatic sync.
|
||||
sync_interval_minutes: Sync interval in minutes.
|
||||
|
||||
Returns:
|
||||
Created VendorLetzshopCredentials.
|
||||
"""
|
||||
# Encrypt the API key
|
||||
encrypted_key = encrypt_value(api_key)
|
||||
|
||||
credentials = VendorLetzshopCredentials(
|
||||
vendor_id=vendor_id,
|
||||
api_key_encrypted=encrypted_key,
|
||||
api_endpoint=api_endpoint or DEFAULT_ENDPOINT,
|
||||
auto_sync_enabled=auto_sync_enabled,
|
||||
sync_interval_minutes=sync_interval_minutes,
|
||||
)
|
||||
|
||||
self.db.add(credentials)
|
||||
self.db.commit()
|
||||
self.db.refresh(credentials)
|
||||
|
||||
logger.info(f"Created Letzshop credentials for vendor {vendor_id}")
|
||||
return credentials
|
||||
|
||||
def update_credentials(
|
||||
self,
|
||||
vendor_id: int,
|
||||
api_key: str | None = None,
|
||||
api_endpoint: str | None = None,
|
||||
auto_sync_enabled: bool | None = None,
|
||||
sync_interval_minutes: int | None = None,
|
||||
) -> VendorLetzshopCredentials:
|
||||
"""
|
||||
Update Letzshop credentials for a vendor.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
api_key: New API key (optional, will be encrypted if provided).
|
||||
api_endpoint: New API endpoint (optional).
|
||||
auto_sync_enabled: New auto-sync setting (optional).
|
||||
sync_interval_minutes: New sync interval (optional).
|
||||
|
||||
Returns:
|
||||
Updated VendorLetzshopCredentials.
|
||||
|
||||
Raises:
|
||||
CredentialsNotFoundError: If credentials are not found.
|
||||
"""
|
||||
credentials = self.get_credentials_or_raise(vendor_id)
|
||||
|
||||
if api_key is not None:
|
||||
credentials.api_key_encrypted = encrypt_value(api_key)
|
||||
if api_endpoint is not None:
|
||||
credentials.api_endpoint = api_endpoint
|
||||
if auto_sync_enabled is not None:
|
||||
credentials.auto_sync_enabled = auto_sync_enabled
|
||||
if sync_interval_minutes is not None:
|
||||
credentials.sync_interval_minutes = sync_interval_minutes
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(credentials)
|
||||
|
||||
logger.info(f"Updated Letzshop credentials for vendor {vendor_id}")
|
||||
return credentials
|
||||
|
||||
def delete_credentials(self, vendor_id: int) -> bool:
|
||||
"""
|
||||
Delete Letzshop credentials for a vendor.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found.
|
||||
"""
|
||||
credentials = self.get_credentials(vendor_id)
|
||||
if credentials is None:
|
||||
return False
|
||||
|
||||
self.db.delete(credentials)
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Deleted Letzshop credentials for vendor {vendor_id}")
|
||||
return True
|
||||
|
||||
def upsert_credentials(
|
||||
self,
|
||||
vendor_id: int,
|
||||
api_key: str,
|
||||
api_endpoint: str | None = None,
|
||||
auto_sync_enabled: bool = False,
|
||||
sync_interval_minutes: int = 15,
|
||||
) -> VendorLetzshopCredentials:
|
||||
"""
|
||||
Create or update Letzshop credentials for a vendor.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
api_key: The Letzshop API key (will be encrypted).
|
||||
api_endpoint: Custom API endpoint (optional).
|
||||
auto_sync_enabled: Whether to enable automatic sync.
|
||||
sync_interval_minutes: Sync interval in minutes.
|
||||
|
||||
Returns:
|
||||
Created or updated VendorLetzshopCredentials.
|
||||
"""
|
||||
existing = self.get_credentials(vendor_id)
|
||||
|
||||
if existing:
|
||||
return self.update_credentials(
|
||||
vendor_id=vendor_id,
|
||||
api_key=api_key,
|
||||
api_endpoint=api_endpoint,
|
||||
auto_sync_enabled=auto_sync_enabled,
|
||||
sync_interval_minutes=sync_interval_minutes,
|
||||
)
|
||||
|
||||
return self.create_credentials(
|
||||
vendor_id=vendor_id,
|
||||
api_key=api_key,
|
||||
api_endpoint=api_endpoint,
|
||||
auto_sync_enabled=auto_sync_enabled,
|
||||
sync_interval_minutes=sync_interval_minutes,
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Key Decryption and Client Creation
|
||||
# ========================================================================
|
||||
|
||||
def get_decrypted_api_key(self, vendor_id: int) -> str:
|
||||
"""
|
||||
Get the decrypted API key for a vendor.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
|
||||
Returns:
|
||||
Decrypted API key.
|
||||
|
||||
Raises:
|
||||
CredentialsNotFoundError: If credentials are not found.
|
||||
"""
|
||||
credentials = self.get_credentials_or_raise(vendor_id)
|
||||
return decrypt_value(credentials.api_key_encrypted)
|
||||
|
||||
def get_masked_api_key(self, vendor_id: int) -> str:
|
||||
"""
|
||||
Get a masked version of the API key for display.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
|
||||
Returns:
|
||||
Masked API key (e.g., "sk-a***************").
|
||||
|
||||
Raises:
|
||||
CredentialsNotFoundError: If credentials are not found.
|
||||
"""
|
||||
api_key = self.get_decrypted_api_key(vendor_id)
|
||||
return mask_api_key(api_key)
|
||||
|
||||
def create_client(self, vendor_id: int) -> LetzshopClient:
|
||||
"""
|
||||
Create a Letzshop client for a vendor.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
|
||||
Returns:
|
||||
Configured LetzshopClient.
|
||||
|
||||
Raises:
|
||||
CredentialsNotFoundError: If credentials are not found.
|
||||
"""
|
||||
credentials = self.get_credentials_or_raise(vendor_id)
|
||||
api_key = decrypt_value(credentials.api_key_encrypted)
|
||||
|
||||
return LetzshopClient(
|
||||
api_key=api_key,
|
||||
endpoint=credentials.api_endpoint,
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Connection Testing
|
||||
# ========================================================================
|
||||
|
||||
def test_connection(
|
||||
self, vendor_id: int
|
||||
) -> tuple[bool, float | None, str | None]:
|
||||
"""
|
||||
Test the connection for a vendor's credentials.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, response_time_ms, error_message).
|
||||
"""
|
||||
try:
|
||||
with self.create_client(vendor_id) as client:
|
||||
return client.test_connection()
|
||||
except CredentialsNotFoundError:
|
||||
return False, None, "Letzshop credentials not configured"
|
||||
except Exception as e:
|
||||
logger.error(f"Connection test failed for vendor {vendor_id}: {e}")
|
||||
return False, None, str(e)
|
||||
|
||||
def test_api_key(
|
||||
self,
|
||||
api_key: str,
|
||||
api_endpoint: str | None = None,
|
||||
) -> tuple[bool, float | None, str | None]:
|
||||
"""
|
||||
Test an API key without saving it.
|
||||
|
||||
Args:
|
||||
api_key: The API key to test.
|
||||
api_endpoint: Optional custom endpoint.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, response_time_ms, error_message).
|
||||
"""
|
||||
try:
|
||||
with LetzshopClient(
|
||||
api_key=api_key,
|
||||
endpoint=api_endpoint or DEFAULT_ENDPOINT,
|
||||
) as client:
|
||||
return client.test_connection()
|
||||
except Exception as e:
|
||||
logger.error(f"API key test failed: {e}")
|
||||
return False, None, str(e)
|
||||
|
||||
# ========================================================================
|
||||
# Sync Status Updates
|
||||
# ========================================================================
|
||||
|
||||
def update_sync_status(
|
||||
self,
|
||||
vendor_id: int,
|
||||
status: str,
|
||||
error: str | None = None,
|
||||
) -> VendorLetzshopCredentials | None:
|
||||
"""
|
||||
Update the last sync status for a vendor.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
status: Sync status (success, failed, partial).
|
||||
error: Error message if sync failed.
|
||||
|
||||
Returns:
|
||||
Updated credentials or None if not found.
|
||||
"""
|
||||
credentials = self.get_credentials(vendor_id)
|
||||
if credentials is None:
|
||||
return None
|
||||
|
||||
credentials.last_sync_at = datetime.now(timezone.utc)
|
||||
credentials.last_sync_status = status
|
||||
credentials.last_sync_error = error
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(credentials)
|
||||
|
||||
return credentials
|
||||
|
||||
# ========================================================================
|
||||
# Status Helpers
|
||||
# ========================================================================
|
||||
|
||||
def is_configured(self, vendor_id: int) -> bool:
|
||||
"""Check if Letzshop is configured for a vendor."""
|
||||
return self.get_credentials(vendor_id) is not None
|
||||
|
||||
def get_status(self, vendor_id: int) -> dict:
|
||||
"""
|
||||
Get the Letzshop integration status for a vendor.
|
||||
|
||||
Args:
|
||||
vendor_id: The vendor ID.
|
||||
|
||||
Returns:
|
||||
Status dictionary with configuration and sync info.
|
||||
"""
|
||||
credentials = self.get_credentials(vendor_id)
|
||||
|
||||
if credentials is None:
|
||||
return {
|
||||
"is_configured": False,
|
||||
"is_connected": False,
|
||||
"last_sync_at": None,
|
||||
"last_sync_status": None,
|
||||
"auto_sync_enabled": False,
|
||||
}
|
||||
|
||||
return {
|
||||
"is_configured": True,
|
||||
"is_connected": credentials.last_sync_status == "success",
|
||||
"last_sync_at": credentials.last_sync_at,
|
||||
"last_sync_status": credentials.last_sync_status,
|
||||
"auto_sync_enabled": credentials.auto_sync_enabled,
|
||||
}
|
||||
319
app/services/letzshop/order_service.py
Normal file
319
app/services/letzshop/order_service.py
Normal file
@@ -0,0 +1,319 @@
|
||||
# app/services/letzshop/order_service.py
|
||||
"""
|
||||
Letzshop order service for handling order-related database operations.
|
||||
|
||||
This service moves database queries out of the API layer to comply with
|
||||
architecture rules (API-002: endpoints should not contain business logic).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.database.letzshop import (
|
||||
LetzshopFulfillmentQueue,
|
||||
LetzshopOrder,
|
||||
LetzshopSyncLog,
|
||||
VendorLetzshopCredentials,
|
||||
)
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VendorNotFoundError(Exception):
|
||||
"""Raised when a vendor is not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class OrderNotFoundError(Exception):
|
||||
"""Raised when a Letzshop order is not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class LetzshopOrderService:
|
||||
"""Service for Letzshop order database operations."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
# =========================================================================
|
||||
# Vendor Operations
|
||||
# =========================================================================
|
||||
|
||||
def get_vendor(self, vendor_id: int) -> Vendor | None:
|
||||
"""Get vendor by ID."""
|
||||
return self.db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
|
||||
def get_vendor_or_raise(self, vendor_id: int) -> Vendor:
|
||||
"""Get vendor by ID or raise VendorNotFoundError."""
|
||||
vendor = self.get_vendor(vendor_id)
|
||||
if vendor is None:
|
||||
raise VendorNotFoundError(f"Vendor with ID {vendor_id} not found")
|
||||
return vendor
|
||||
|
||||
def list_vendors_with_letzshop_status(
|
||||
self,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
configured_only: bool = False,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""
|
||||
List vendors with their Letzshop integration status.
|
||||
|
||||
Returns a tuple of (vendor_overviews, total_count).
|
||||
"""
|
||||
# Build query
|
||||
query = self.db.query(Vendor).filter(Vendor.is_active == True) # noqa: E712
|
||||
|
||||
if configured_only:
|
||||
query = query.join(
|
||||
VendorLetzshopCredentials,
|
||||
Vendor.id == VendorLetzshopCredentials.vendor_id,
|
||||
)
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
|
||||
# Get vendors
|
||||
vendors = query.order_by(Vendor.name).offset(skip).limit(limit).all()
|
||||
|
||||
# Build response with Letzshop status
|
||||
vendor_overviews = []
|
||||
for vendor in vendors:
|
||||
# Get credentials
|
||||
credentials = (
|
||||
self.db.query(VendorLetzshopCredentials)
|
||||
.filter(VendorLetzshopCredentials.vendor_id == vendor.id)
|
||||
.first()
|
||||
)
|
||||
|
||||
# Get order counts
|
||||
pending_orders = 0
|
||||
total_orders = 0
|
||||
if credentials:
|
||||
pending_orders = (
|
||||
self.db.query(func.count(LetzshopOrder.id))
|
||||
.filter(
|
||||
LetzshopOrder.vendor_id == vendor.id,
|
||||
LetzshopOrder.sync_status == "pending",
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
total_orders = (
|
||||
self.db.query(func.count(LetzshopOrder.id))
|
||||
.filter(LetzshopOrder.vendor_id == vendor.id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
vendor_overviews.append({
|
||||
"vendor_id": vendor.id,
|
||||
"vendor_name": vendor.name,
|
||||
"vendor_code": vendor.vendor_code,
|
||||
"is_configured": credentials is not None,
|
||||
"auto_sync_enabled": credentials.auto_sync_enabled if credentials else False,
|
||||
"last_sync_at": credentials.last_sync_at if credentials else None,
|
||||
"last_sync_status": credentials.last_sync_status if credentials else None,
|
||||
"pending_orders": pending_orders,
|
||||
"total_orders": total_orders,
|
||||
})
|
||||
|
||||
return vendor_overviews, total
|
||||
|
||||
# =========================================================================
|
||||
# Order Operations
|
||||
# =========================================================================
|
||||
|
||||
def get_order(self, vendor_id: int, order_id: int) -> LetzshopOrder | None:
|
||||
"""Get a Letzshop order by ID for a specific vendor."""
|
||||
return (
|
||||
self.db.query(LetzshopOrder)
|
||||
.filter(
|
||||
LetzshopOrder.id == order_id,
|
||||
LetzshopOrder.vendor_id == vendor_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_order_or_raise(self, vendor_id: int, order_id: int) -> LetzshopOrder:
|
||||
"""Get a Letzshop order or raise OrderNotFoundError."""
|
||||
order = self.get_order(vendor_id, order_id)
|
||||
if order is None:
|
||||
raise OrderNotFoundError(f"Letzshop order {order_id} not found")
|
||||
return order
|
||||
|
||||
def get_order_by_shipment_id(
|
||||
self, vendor_id: int, shipment_id: str
|
||||
) -> LetzshopOrder | None:
|
||||
"""Get a Letzshop order by shipment ID."""
|
||||
return (
|
||||
self.db.query(LetzshopOrder)
|
||||
.filter(
|
||||
LetzshopOrder.vendor_id == vendor_id,
|
||||
LetzshopOrder.letzshop_shipment_id == shipment_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def list_orders(
|
||||
self,
|
||||
vendor_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
sync_status: str | None = None,
|
||||
letzshop_state: str | None = None,
|
||||
) -> tuple[list[LetzshopOrder], int]:
|
||||
"""
|
||||
List Letzshop orders for a vendor.
|
||||
|
||||
Returns a tuple of (orders, total_count).
|
||||
"""
|
||||
query = self.db.query(LetzshopOrder).filter(
|
||||
LetzshopOrder.vendor_id == vendor_id
|
||||
)
|
||||
|
||||
if sync_status:
|
||||
query = query.filter(LetzshopOrder.sync_status == sync_status)
|
||||
if letzshop_state:
|
||||
query = query.filter(LetzshopOrder.letzshop_state == letzshop_state)
|
||||
|
||||
total = query.count()
|
||||
orders = (
|
||||
query.order_by(LetzshopOrder.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return orders, total
|
||||
|
||||
def create_order(
|
||||
self,
|
||||
vendor_id: int,
|
||||
shipment_data: dict[str, Any],
|
||||
) -> LetzshopOrder:
|
||||
"""Create a new Letzshop order from shipment data."""
|
||||
order_data = shipment_data.get("order", {})
|
||||
|
||||
order = LetzshopOrder(
|
||||
vendor_id=vendor_id,
|
||||
letzshop_order_id=order_data.get("id", ""),
|
||||
letzshop_shipment_id=shipment_data["id"],
|
||||
letzshop_order_number=order_data.get("number"),
|
||||
letzshop_state=shipment_data.get("state"),
|
||||
customer_email=order_data.get("email"),
|
||||
total_amount=str(
|
||||
order_data.get("totalPrice", {}).get("amount", "")
|
||||
),
|
||||
currency=order_data.get("totalPrice", {}).get("currency", "EUR"),
|
||||
raw_order_data=shipment_data,
|
||||
inventory_units=[
|
||||
{"id": u["id"], "state": u["state"]}
|
||||
for u in shipment_data.get("inventoryUnits", {}).get("nodes", [])
|
||||
],
|
||||
sync_status="pending",
|
||||
)
|
||||
self.db.add(order)
|
||||
return order
|
||||
|
||||
def update_order_from_shipment(
|
||||
self,
|
||||
order: LetzshopOrder,
|
||||
shipment_data: dict[str, Any],
|
||||
) -> LetzshopOrder:
|
||||
"""Update an existing order from shipment data."""
|
||||
order.letzshop_state = shipment_data.get("state")
|
||||
order.raw_order_data = shipment_data
|
||||
return order
|
||||
|
||||
def mark_order_confirmed(self, order: LetzshopOrder) -> LetzshopOrder:
|
||||
"""Mark an order as confirmed."""
|
||||
order.confirmed_at = datetime.now(timezone.utc)
|
||||
order.sync_status = "confirmed"
|
||||
return order
|
||||
|
||||
def mark_order_rejected(self, order: LetzshopOrder) -> LetzshopOrder:
|
||||
"""Mark an order as rejected."""
|
||||
order.rejected_at = datetime.now(timezone.utc)
|
||||
order.sync_status = "rejected"
|
||||
return order
|
||||
|
||||
def set_order_tracking(
|
||||
self,
|
||||
order: LetzshopOrder,
|
||||
tracking_number: str,
|
||||
tracking_carrier: str,
|
||||
) -> LetzshopOrder:
|
||||
"""Set tracking information for an order."""
|
||||
order.tracking_number = tracking_number
|
||||
order.tracking_carrier = tracking_carrier
|
||||
order.tracking_set_at = datetime.now(timezone.utc)
|
||||
order.sync_status = "shipped"
|
||||
return order
|
||||
|
||||
# =========================================================================
|
||||
# Sync Log Operations
|
||||
# =========================================================================
|
||||
|
||||
def list_sync_logs(
|
||||
self,
|
||||
vendor_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> tuple[list[LetzshopSyncLog], int]:
|
||||
"""
|
||||
List sync logs for a vendor.
|
||||
|
||||
Returns a tuple of (logs, total_count).
|
||||
"""
|
||||
query = self.db.query(LetzshopSyncLog).filter(
|
||||
LetzshopSyncLog.vendor_id == vendor_id
|
||||
)
|
||||
total = query.count()
|
||||
logs = (
|
||||
query.order_by(LetzshopSyncLog.started_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return logs, total
|
||||
|
||||
# =========================================================================
|
||||
# Fulfillment Queue Operations
|
||||
# =========================================================================
|
||||
|
||||
def list_fulfillment_queue(
|
||||
self,
|
||||
vendor_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
status: str | None = None,
|
||||
) -> tuple[list[LetzshopFulfillmentQueue], int]:
|
||||
"""
|
||||
List fulfillment queue items for a vendor.
|
||||
|
||||
Returns a tuple of (items, total_count).
|
||||
"""
|
||||
query = self.db.query(LetzshopFulfillmentQueue).filter(
|
||||
LetzshopFulfillmentQueue.vendor_id == vendor_id
|
||||
)
|
||||
|
||||
if status:
|
||||
query = query.filter(LetzshopFulfillmentQueue.status == status)
|
||||
|
||||
total = query.count()
|
||||
items = (
|
||||
query.order_by(LetzshopFulfillmentQueue.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return items, total
|
||||
187
app/utils/encryption.py
Normal file
187
app/utils/encryption.py
Normal file
@@ -0,0 +1,187 @@
|
||||
# app/utils/encryption.py
|
||||
"""
|
||||
Encryption utilities for sensitive data storage.
|
||||
|
||||
Uses Fernet symmetric encryption with key derivation from the JWT secret.
|
||||
Provides secure storage for API keys and other sensitive credentials.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Salt for key derivation - fixed to ensure consistent encryption/decryption
|
||||
# In production, this should be stored securely and not changed
|
||||
_ENCRYPTION_SALT = b"wizamart_encryption_salt_v1"
|
||||
|
||||
|
||||
class EncryptionError(Exception):
|
||||
"""Raised when encryption or decryption fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class EncryptionService:
|
||||
"""
|
||||
Service for encrypting and decrypting sensitive data.
|
||||
|
||||
Uses Fernet symmetric encryption with a key derived from the application's
|
||||
JWT secret key. This ensures that encrypted data can only be decrypted
|
||||
by the same application instance with the same secret.
|
||||
"""
|
||||
|
||||
def __init__(self, secret_key: str | None = None):
|
||||
"""
|
||||
Initialize the encryption service.
|
||||
|
||||
Args:
|
||||
secret_key: The secret key to derive the encryption key from.
|
||||
Defaults to the JWT secret key from settings.
|
||||
"""
|
||||
if secret_key is None:
|
||||
secret_key = settings.jwt_secret_key
|
||||
|
||||
self._fernet = self._create_fernet(secret_key)
|
||||
|
||||
def _create_fernet(self, secret_key: str) -> Fernet:
|
||||
"""
|
||||
Create a Fernet instance with a derived key.
|
||||
|
||||
Uses PBKDF2 to derive a 32-byte key from the secret,
|
||||
then encodes it as base64 for Fernet.
|
||||
"""
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=_ENCRYPTION_SALT,
|
||||
iterations=100000,
|
||||
)
|
||||
derived_key = kdf.derive(secret_key.encode())
|
||||
fernet_key = base64.urlsafe_b64encode(derived_key)
|
||||
return Fernet(fernet_key)
|
||||
|
||||
def encrypt(self, plaintext: str) -> str:
|
||||
"""
|
||||
Encrypt a plaintext string.
|
||||
|
||||
Args:
|
||||
plaintext: The string to encrypt.
|
||||
|
||||
Returns:
|
||||
Base64-encoded ciphertext string.
|
||||
|
||||
Raises:
|
||||
EncryptionError: If encryption fails.
|
||||
"""
|
||||
if not plaintext:
|
||||
raise EncryptionError("Cannot encrypt empty string")
|
||||
|
||||
try:
|
||||
ciphertext = self._fernet.encrypt(plaintext.encode())
|
||||
return ciphertext.decode()
|
||||
except Exception as e:
|
||||
logger.error(f"Encryption failed: {e}")
|
||||
raise EncryptionError(f"Failed to encrypt data: {e}") from e
|
||||
|
||||
def decrypt(self, ciphertext: str) -> str:
|
||||
"""
|
||||
Decrypt a ciphertext string.
|
||||
|
||||
Args:
|
||||
ciphertext: Base64-encoded ciphertext to decrypt.
|
||||
|
||||
Returns:
|
||||
Decrypted plaintext string.
|
||||
|
||||
Raises:
|
||||
EncryptionError: If decryption fails (invalid token or corrupted data).
|
||||
"""
|
||||
if not ciphertext:
|
||||
raise EncryptionError("Cannot decrypt empty string")
|
||||
|
||||
try:
|
||||
plaintext = self._fernet.decrypt(ciphertext.encode())
|
||||
return plaintext.decode()
|
||||
except InvalidToken as e:
|
||||
logger.error("Decryption failed: Invalid token")
|
||||
raise EncryptionError(
|
||||
"Failed to decrypt data: Invalid or corrupted ciphertext"
|
||||
) from e
|
||||
except Exception as e:
|
||||
logger.error(f"Decryption failed: {e}")
|
||||
raise EncryptionError(f"Failed to decrypt data: {e}") from e
|
||||
|
||||
def is_valid_ciphertext(self, ciphertext: str) -> bool:
|
||||
"""
|
||||
Check if a string is valid ciphertext that can be decrypted.
|
||||
|
||||
Args:
|
||||
ciphertext: String to validate.
|
||||
|
||||
Returns:
|
||||
True if the string can be decrypted, False otherwise.
|
||||
"""
|
||||
try:
|
||||
self.decrypt(ciphertext)
|
||||
return True
|
||||
except EncryptionError:
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance using the JWT secret key
|
||||
encryption_service = EncryptionService()
|
||||
|
||||
|
||||
def encrypt_value(value: str) -> str:
|
||||
"""
|
||||
Convenience function to encrypt a value using the default service.
|
||||
|
||||
Args:
|
||||
value: The string to encrypt.
|
||||
|
||||
Returns:
|
||||
Encrypted string.
|
||||
"""
|
||||
return encryption_service.encrypt(value)
|
||||
|
||||
|
||||
def decrypt_value(value: str) -> str:
|
||||
"""
|
||||
Convenience function to decrypt a value using the default service.
|
||||
|
||||
Args:
|
||||
value: The encrypted string to decrypt.
|
||||
|
||||
Returns:
|
||||
Decrypted string.
|
||||
"""
|
||||
return encryption_service.decrypt(value)
|
||||
|
||||
|
||||
def mask_api_key(api_key: str, visible_chars: int = 4) -> str:
|
||||
"""
|
||||
Mask an API key for display purposes.
|
||||
|
||||
Shows only the first few characters, replacing the rest with asterisks.
|
||||
|
||||
Args:
|
||||
api_key: The API key to mask.
|
||||
visible_chars: Number of characters to show at the start.
|
||||
|
||||
Returns:
|
||||
Masked API key string (e.g., "sk-a***************").
|
||||
"""
|
||||
if not api_key:
|
||||
return ""
|
||||
|
||||
if len(api_key) <= visible_chars:
|
||||
return "*" * len(api_key)
|
||||
|
||||
return api_key[:visible_chars] + "*" * (len(api_key) - visible_chars)
|
||||
Reference in New Issue
Block a user