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:
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
|
||||
Reference in New Issue
Block a user