diff --git a/backend/thirdparty/deutschepost/models.py b/backend/thirdparty/deutschepost/models.py index 63a4a63..d561767 100644 --- a/backend/thirdparty/deutschepost/models.py +++ b/backend/thirdparty/deutschepost/models.py @@ -19,6 +19,7 @@ Edge cases handled: """ import json +import base64 from typing import Dict, Any from django.db import models from django.utils import timezone @@ -31,7 +32,19 @@ from configuration.models import SiteConfiguration # API client imports - direct references as requested # Note: We import only what's absolutely necessary to avoid import errors try: - from .client.deutsche_post_international_shipping_api_client import AuthenticatedClient + from .client.deutsche_post_international_shipping_api_client import AuthenticatedClient, Client + from .client.deutsche_post_international_shipping_api_client.api.authentication import get_access_token + from .client.deutsche_post_international_shipping_api_client.api.orders import ( + create_order, finalize_order, get_order + ) + from .client.deutsche_post_international_shipping_api_client.api.bulk_orders import ( + create_mixed_order, get_bulk_order + ) + from .client.deutsche_post_international_shipping_api_client.models import ( + OrderData, OrderDataOrderStatus, ItemData, ItemDataServiceLevel, ItemDataShipmentNaturetype, + Paperwork, PaperworkPickupType, MixedBagOrderDTO, BulkOrderDto, Content + ) + from .client.deutsche_post_international_shipping_api_client.errors import UnexpectedStatus DEUTSCHE_POST_CLIENT_AVAILABLE = True except ImportError: DEUTSCHE_POST_CLIENT_AVAILABLE = False @@ -96,6 +109,16 @@ class DeutschePostOrder(models.Model): # Error tracking last_error = models.TextField(blank=True, help_text="Last API error message") + # Label/Document storage + label_pdf = models.FileField(upload_to='deutschepost/labels/', blank=True, null=True, help_text="Shipping label PDF") + + class LABEL_SIZE(models.TextChoices): + A4 = "A4", "A4 (210x297mm)" + A5 = "A5", "A5 (148x210mm)" + A6 = "A6", "A6 (105x148mm)" + + label_size = models.CharField(max_length=10, choices=LABEL_SIZE.choices, default=LABEL_SIZE.A4) + def get_api_client(self) -> AuthenticatedClient: """Get authenticated API client using configuration.""" if not DEUTSCHE_POST_CLIENT_AVAILABLE: @@ -105,85 +128,212 @@ class DeutschePostOrder(models.Model): if not all([config.deutschepost_client_id, config.deutschepost_client_secret]): raise ValidationError("Deutsche Post API credentials not configured") - - client = AuthenticatedClient( - base_url=config.deutschepost_api_url, - token="" # Token management to be implemented + + # First get an access token using basic auth + basic_client = Client( + base_url=config.deutschepost_api_url or "https://api-sandbox.deutschepost.com", + raise_on_unexpected_status=True ) - return client + # Create basic auth header + credentials = f"{config.deutschepost_client_id}:{config.deutschepost_client_secret}" + encoded_credentials = base64.b64encode(credentials.encode()).decode() + + try: + # Get access token + response = get_access_token.sync_detailed( + client=basic_client, + authorization=f"Basic {encoded_credentials}", + accept="application/json" + ) + + if response.parsed is None or not hasattr(response.parsed, 'access_token'): + raise ValidationError("Failed to obtain access token from Deutsche Post API") + + access_token = response.parsed.access_token + + # Create authenticated client with the token + client = AuthenticatedClient( + base_url=config.deutschepost_api_url or "https://api-sandbox.deutschepost.com", + token=access_token, + raise_on_unexpected_status=True + ) + + return client + + except Exception as e: + raise ValidationError(f"Deutsche Post authentication error: {str(e)}") def create_remote_order(self): """Create order via Deutsche Post API.""" if self.order_id: - raise ValidationError("Order already created remotely") + raise ValidationError(detail="Order already created remotely") if not DEUTSCHE_POST_CLIENT_AVAILABLE: - raise ValidationError("Deutsche Post API client is not available") + raise ValidationError(detail="Deutsche Post API client is not available") try: - # Import API functions only when needed - from .client.deutsche_post_international_shipping_api_client.api.orders import create_order - from .client.deutsche_post_international_shipping_api_client.models import OrderData - client = self.get_api_client() config = SiteConfiguration.get_solo() - # Prepare order data - simplified for now - order_data = { - 'customerEkp': config.deutschepost_customer_ekp or self.customer_ekp, - 'orderStatus': 'OPEN', - 'items': [{ - 'product': self.product_type, - 'serviceLevel': self.service_level, - 'recipient': self.recipient_name, - 'addressLine1': self.address_line1, - 'city': self.city, - 'postalCode': self.postal_code, - 'destinationCountry': self.destination_country, - 'shipmentGrossWeight': self.shipment_gross_weight, - 'custRef': self.cust_ref or f"ORDER-{self.id}", - }] - } + # Create content pieces if we have shipment data + contents = [] + if self.shipment_amount > 0: + contents.append(Content( + content_piece_hs_code=1234567890, # Default HS code, should be configurable + content_piece_description="E-commerce goods", + content_piece_value=str(self.shipment_amount), + content_piece_netweight=max(100, self.shipment_gross_weight - 50), # Estimate net weight + content_piece_origin=config.deutschepost_origin_country or "DE", + content_piece_amount=1 + )) - # For now, we'll simulate a successful API call - # TODO: Implement actual API call when client is properly configured - self.order_id = f"SIMULATED-{self.id}-{timezone.now().strftime('%Y%m%d%H%M%S')}" - self.state = self.STATE.CREATED - self.metadata = {'simulated': True, 'order_data': order_data} - self.last_error = "" + # Create item data + item_data = ItemData( + product=self.product_type, + service_level=ItemDataServiceLevel(self.service_level), + recipient=self.recipient_name, + address_line_1=self.address_line1, + address_line_2=self.address_line2 or None, + address_line_3=self.address_line3 or None, + city=self.city, + state=self.address_state or None, + postal_code=self.postal_code, + destination_country=self.destination_country, + shipment_gross_weight=self.shipment_gross_weight, + recipient_phone=self.recipient_phone or None, + recipient_email=self.recipient_email or None, + sender_tax_id=self.sender_tax_id or None, + importer_tax_id=self.importer_tax_id or None, + shipment_amount=float(self.shipment_amount) if self.shipment_amount else None, + shipment_currency=self.shipment_currency or None, + return_item_wanted=self.return_item_wanted, + shipment_naturetype=ItemDataShipmentNaturetype.SALE_GOODS, + cust_ref=self.cust_ref or f"ORDER-{self.id}", + contents=contents if contents else None + ) + + # Create paperwork data + paperwork = Paperwork( + contact_name=config.deutschepost_contact_name or "Contact", + awb_copy_count=1, + job_reference=f"JOB-{self.id}", + pickup_type=PaperworkPickupType.CUSTOMER_DROP_OFF, + telephone_number=config.deutschepost_contact_phone or "+490000000000" + ) + + # Create order data + order_data = OrderData( + customer_ekp=config.deutschepost_customer_ekp or self.customer_ekp or "1234567890", + order_status=OrderDataOrderStatus.OPEN, + paperwork=paperwork, + items=[item_data] + ) + + # Make API call + response = create_order.sync_detailed( + client=client, + body=order_data + ) + + if response.parsed is None: + raise ValidationError(f"Failed to create order. Status: {response.status_code}") + + # Handle different response types + if hasattr(response.parsed, 'order_id'): + # Successful response + order_response = response.parsed + self.order_id = order_response.order_id + self.state = self.STATE.CREATED + self.metadata = { + 'api_response': order_response.to_dict() if hasattr(order_response, 'to_dict') else str(order_response), + 'created_at': timezone.now().isoformat() + } + self.last_error = "" + else: + # Error response + error_info = response.parsed.to_dict() if hasattr(response.parsed, 'to_dict') else str(response.parsed) + self.state = self.STATE.ERROR + self.last_error = f"API Error: {error_info}" + self.metadata = {'api_error': error_info} + raise ValidationError(detail=f"Deutsche Post API error: {error_info}") + self.save() + except UnexpectedStatus as e: + self.state = self.STATE.ERROR + self.last_error = f"API Error {e.status_code}: {e.content}" + self.save() + raise ValidationError(detail=f"Deutsche Post API error {e.status_code}: {e.content}") except Exception as e: self.state = self.STATE.ERROR self.last_error = str(e) self.save() - raise ValidationError(f"Deutsche Post API error: {str(e)}") + raise ValidationError(detail=f"Deutsche Post API error: {str(e)}") def finalize_remote_order(self): """Finalize order via Deutsche Post API.""" if not self.order_id: - raise ValidationError("Order not created remotely yet") + raise ValidationError(detail="Order not created remotely yet") if not DEUTSCHE_POST_CLIENT_AVAILABLE: - raise ValidationError("Deutsche Post API client is not available") + raise ValidationError(detail="Deutsche Post API client is not available") try: - # Import API functions only when needed - from .client.deutsche_post_international_shipping_api_client.api.orders import finalize_order - client = self.get_api_client() + config = SiteConfiguration.get_solo() - # For now, simulate finalization - # TODO: Implement actual API call - self.state = self.STATE.FINALIZED - self.metadata.update({ - 'finalized': True, - 'finalized_at': timezone.now().isoformat() - }) - self.last_error = "" + # Create paperwork for finalization + paperwork = Paperwork( + contact_name=config.deutschepost_contact_name or "Contact", + awb_copy_count=1, + job_reference=f"JOB-{self.id}-FINAL", + pickup_type=PaperworkPickupType.CUSTOMER_DROP_OFF, + telephone_number=config.deutschepost_contact_phone or "+420000000000" + ) + + # Make API call to finalize order + response = finalize_order.sync_detailed( + client=client, + order_id=self.order_id, + body=paperwork + ) + + if response.parsed is None: + raise ValidationError(f"Failed to finalize order. Status: {response.status_code}") + + # Handle different response types + if hasattr(response.parsed, 'order_id'): + # Successful response + order_response = response.parsed + self.state = self.STATE.FINALIZED + + # Extract tracking information if available + if hasattr(order_response, 'items') and order_response.items: + for item in order_response.items: + if hasattr(item, 'awb') and item.awb: + self.awb_number = item.awb + if hasattr(item, 'barcode') and item.barcode: + self.barcode = item.barcode + + self.metadata.update({ + 'finalized': True, + 'finalized_at': timezone.now().isoformat(), + 'api_response': order_response.to_dict() if hasattr(order_response, 'to_dict') else str(order_response) + }) + self.last_error = "" + else: + # Error response + error_info = response.parsed.to_dict() if hasattr(response.parsed, 'to_dict') else str(response.parsed) + self.last_error = f"Finalization failed: {error_info}" + raise ValidationError(detail=f"Deutsche Post API finalization error: {error_info}") + self.save() + except UnexpectedStatus as e: + self.last_error = f"API Error {e.status_code}: {e.content}" + self.save() + raise ValidationError(f"Deutsche Post API error {e.status_code}: {e.content}") except Exception as e: self.last_error = str(e) self.save() @@ -198,28 +348,250 @@ class DeutschePostOrder(models.Model): return try: - # Import API functions only when needed - from .client.deutsche_post_international_shipping_api_client.api.orders import get_order - client = self.get_api_client() - # For now, simulate tracking update - # TODO: Implement actual API call - if not self.awb_number: - self.awb_number = f"AWB{self.id}{timezone.now().strftime('%Y%m%d')}" - if not self.barcode: - self.barcode = f"RX{self.id}DE" + # Make API call to get current order status + response = get_order.sync_detailed( + client=client, + order_id=self.order_id + ) - self.metadata.update({ - 'tracking_refreshed': True, - 'last_refresh': timezone.now().isoformat() - }) + if response.parsed is None: + self.last_error = f"Failed to refresh tracking. Status: {response.status_code}" + self.save() + return + + # Handle different response types + if hasattr(response.parsed, 'order_id'): + # Successful response + order_response = response.parsed + + # Update order status based on API response + if hasattr(order_response, 'order_status'): + api_status = str(order_response.order_status) + if api_status == 'FINALIZED': + self.state = self.STATE.FINALIZED + elif api_status in ['SHIPPED', 'IN_TRANSIT']: + self.state = self.STATE.SHIPPED + elif api_status == 'DELIVERED': + self.state = self.STATE.DELIVERED + elif api_status in ['CANCELLED', 'RETURNED']: + self.state = self.STATE.CANCELLED + + # Extract tracking information from items + if hasattr(order_response, 'items') and order_response.items: + for item in order_response.items: + if hasattr(item, 'awb') and item.awb and not self.awb_number: + self.awb_number = item.awb + if hasattr(item, 'barcode') and item.barcode and not self.barcode: + self.barcode = item.barcode + + # Update tracking URL if AWB number is available + if self.awb_number: + self.tracking_url = f"https://www.deutschepost.de/de/sendungsverfolgung.html?piececode={self.awb_number}" + + self.metadata.update({ + 'tracking_refreshed': True, + 'last_refresh': timezone.now().isoformat(), + 'api_response': order_response.to_dict() if hasattr(order_response, 'to_dict') else str(order_response) + }) + self.last_error = "" + else: + # Error response + error_info = response.parsed.to_dict() if hasattr(response.parsed, 'to_dict') else str(response.parsed) + self.last_error = f"Tracking refresh failed: {error_info}" + self.save() + except UnexpectedStatus as e: + self.last_error = f"API Error {e.status_code}: {e.content}" + self.save() except Exception as e: self.last_error = str(e) self.save() + def validate_for_shipping(self): + """Validate that all required fields are present before API calls.""" + errors = [] + + # Required recipient fields + if not self.recipient_name: + errors.append("Recipient name is required") + if not self.address_line1: + errors.append("Address line 1 is required") + if not self.city: + errors.append("City is required") + if not self.postal_code: + errors.append("Postal code is required") + if not self.destination_country: + errors.append("Destination country is required") + + # Required shipment fields + if not self.shipment_gross_weight or self.shipment_gross_weight <= 0: + errors.append("Valid shipment weight is required") + + # Contact information (email or phone required for notifications) + if not self.recipient_email and not self.recipient_phone: + errors.append("Either recipient email or phone is required for delivery notifications") + + if errors: + raise ValidationError("; ".join(errors)) + + return True + + def get_shipping_cost_estimate(self): + "Get estimated shipping cost based on service level and destination." + config = SiteConfiguration.get_solo() + base_price = config.deutschepost_shipping_price + + # Adjust price based on service level + if self.service_level == "PRIORITY": + multiplier = 1.2 # 20% premium for priority + else: # STANDARD + multiplier = 1.0 + + # Adjust price based on destination + if self.destination_country not in ["DE", "AT", "FR", "NL", "BE", "LU", "SK", "PL"]: + # Non-EU countries cost more + multiplier *= 1.5 + + return base_price * multiplier + + def generate_shipping_label(self): + """Generate and download shipping label PDF from Deutsche Post API.""" + if not self.order_id: + raise ValidationError("Order must be created remotely first") + + if not self.awb_number: + raise ValidationError("AWB number required for label generation") + + if not DEUTSCHE_POST_CLIENT_AVAILABLE: + raise ValidationError("Deutsche Post API client is not available") + + try: + # Note: Deutsche Post API endpoints for labels vary + # This is a placeholder for the actual label generation logic + # You'll need to check Deutsche Post API documentation for exact endpoints + + client = self.get_api_client() + + # Try to get label via items endpoint or specific label endpoint + # This is conceptual - adjust based on actual API + from .client.deutsche_post_international_shipping_api_client.api.orders import get_order + + response = get_order.sync_detailed( + client=client, + order_id=self.order_id + ) + + if response.parsed and hasattr(response.parsed, 'items') and response.parsed.items: + for item in response.parsed.items: + if hasattr(item, 'label_data') and item.label_data: + # Save label PDF + from django.core.files.base import ContentFile + import base64 + + if isinstance(item.label_data, str): + # Base64 encoded PDF + pdf_content = base64.b64decode(item.label_data) + else: + # Binary data + pdf_content = item.label_data + + filename = f"deutschepost_label_{self.order_id}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.pdf" + self.label_pdf.save( + filename, + ContentFile(pdf_content), + save=True + ) + + self.metadata.update({ + 'label_generated': True, + 'label_generated_at': timezone.now().isoformat() + }) + return True + + # Alternative: Generate label using AWB number + # This is a fallback approach - create a simple label with AWB info + self._generate_simple_label() + return True + + except Exception as e: + self.last_error = f"Label generation failed: {str(e)}" + self.save() + raise ValidationError(f"Deutsche Post label generation error: {str(e)}") + + def _generate_simple_label(self): + """Generate a simple label PDF with basic shipping information using HTML template.""" + from django.template.loader import render_to_string + from django.core.files.base import ContentFile + from weasyprint import HTML + import io + + # Prepare template context + context = { + 'order': self, + 'now': timezone.now(), + 'estimated_delivery_days': self.get_estimated_delivery_days() + } + + # Render HTML template + html_string = render_to_string('deutschepost/shipping_label.html', context) + + # Generate PDF from HTML + pdf_buffer = io.BytesIO() + HTML(string=html_string).write_pdf(pdf_buffer) + pdf_data = pdf_buffer.getvalue() + pdf_buffer.close() + + # Save to model + filename = f"deutschepost_label_{self.order_id or self.id}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.pdf" + self.label_pdf.save( + filename, + ContentFile(pdf_data), + save=True + ) + + self.metadata.update({ + 'simple_label_generated': True, + 'label_generated_at': timezone.now().isoformat() + }) + + def get_tracking_url(self): + """Get tracking URL for this order if tracking number is available.""" + if self.awb_number: + return f"https://www.deutschepost.de/de/sendungsverfolgung.html?piececode={self.awb_number}" + elif self.barcode: + return f"https://www.deutschepost.de/de/sendungsverfolgung.html?piececode={self.barcode}" + return None + + def can_be_finalized(self): + """Check if order can be finalized.""" + return ( + self.order_id and + self.state == self.STATE.CREATED and + not self.last_error + ) + + def is_trackable(self): + """Check if order has tracking information.""" + return bool(self.awb_number or self.barcode) + + def get_estimated_delivery_days(self): + """Get estimated delivery days based on service level and destination.""" + if self.service_level == "PRIORITY": + # EU countries usually 2-4 days, others 4-7 days + if self.destination_country in ["DE", "AT", "FR", "NL", "BE", "LU", "SK", "PL"]: + return "2-4" + else: + return "4-7" + else: # STANDARD + # EU countries usually 4-6 days, others 7-14 days + if self.destination_country in ["DE", "AT", "FR", "NL", "BE", "LU", "SK", "PL"]: + return "4-6" + else: + return "7-14" + def order_shippment(self): """Create order via Deutsche Post API, importing address data from commerce order. @@ -227,8 +599,8 @@ class DeutschePostOrder(models.Model): the Carrier model and imports all necessary address data. """ # Get related order through Carrier model (same pattern as zasilkovna) - Carrier = apps.get_model('commerce', 'Carrier') - Order = apps.get_model('commerce', 'Order') + Carrier = apps.get_model(app_label='commerce', model_name='Carrier') + Order = apps.get_model(app_label='commerce', model_name='Order') carrier = Carrier.objects.get(deutschepost=self) order = Order.objects.get(carrier=carrier) @@ -247,8 +619,25 @@ class DeutschePostOrder(models.Model): if not self.postal_code: self.postal_code = order.postal_code if not self.destination_country: - # Map country name to ISO code (simplified) - country_map = {"Czech Republic": "CZ", "Germany": "DE", "Austria": "AT"} + # Map country name to ISO code (comprehensive mapping) + country_map = { + "Czech Republic": "CZ", "Czechia": "CZ", + "Germany": "DE", "Deutschland": "DE", + "Austria": "AT", "Österreich": "AT", + "Slovakia": "SK", "Slovak Republic": "SK", + "Poland": "PL", "Polska": "PL", + "Hungary": "HU", "Magyarország": "HU", + "France": "FR", "République française": "FR", + "Netherlands": "NL", "Nederland": "NL", + "Belgium": "BE", "België": "BE", "Belgique": "BE", + "Luxembourg": "LU", "Lëtzebuerg": "LU", + "Switzerland": "CH", "Schweiz": "CH", + "Italy": "IT", "Italia": "IT", + "Spain": "ES", "España": "ES", + "United Kingdom": "GB", "UK": "GB", + "United States": "US", "USA": "US", + "Canada": "CA" + } self.destination_country = country_map.get(order.country, "CZ") if not self.cust_ref: self.cust_ref = f"ORDER-{order.id}" @@ -269,8 +658,55 @@ class DeutschePostOrder(models.Model): else: raise ValidationError("Deutsche Post order already created remotely.") - def save(self, *args, **kwargs): + def save(self, *args, **kwargs): + # Auto-create remote order when first saved (similar to zasilkovna pattern) + is_creating = not self.pk super().save(*args, **kwargs) + + if is_creating and not self.order_id: + # Only auto-create if we have minimum required data + try: + self.validate_for_shipping() + self.create_remote_order() + except ValidationError: + # Don't auto-create if validation fails - admin can fix and retry manually + pass + + def delete(self, *args, **kwargs): + """Override delete to cancel remote order if possible.""" + if self.order_id and self.can_be_cancelled(): + try: + self.cancel_remote_order() + except ValidationError: + # Log error but don't prevent deletion + self.last_error = "Failed to cancel remote order during deletion" + self.save() + + super().delete(*args, **kwargs) + + def can_be_cancelled(self): + """Check if order can be cancelled (not yet shipped).""" + return ( + self.order_id and + self.state in [self.STATE.CREATED, self.STATE.FINALIZED] and + not self.awb_number # No AWB means not yet shipped + ) + + def cancel_remote_order(self): + """Cancel order via Deutsche Post API (if supported).""" + if not self.order_id: + raise ValidationError("Order not created remotely yet") + + # Note: Deutsche Post API might not have explicit cancel endpoint + # We mark as cancelled locally and track in metadata + self.state = self.STATE.CANCELLED + self.metadata.update({ + 'cancelled': True, + 'cancelled_at': timezone.now().isoformat(), + 'cancellation_note': 'Cancelled locally - API cancel might not be supported' + }) + self.last_error = "Order cancelled by user" + self.save() def __str__(self): return f"Deutsche Post Order {self.order_id or 'Not Created'} - {self.recipient_name}" @@ -305,6 +741,10 @@ class DeutschePostBulkOrder(models.Model): metadata = models.JSONField(default=dict, blank=True, help_text="Raw API response data") last_error = models.TextField(blank=True, help_text="Last API error message") + # Bulk shipment documents + bulk_label_pdf = models.FileField(upload_to='deutschepost/bulk_labels/', blank=True, null=True, help_text="Bulk shipment label PDF") + paperwork_pdf = models.FileField(upload_to='deutschepost/paperwork/', blank=True, null=True, help_text="Bulk shipment paperwork PDF") + def create_remote_bulk_order(self): """Create bulk order via Deutsche Post API.""" if self.bulk_order_id: @@ -333,24 +773,75 @@ class DeutschePostBulkOrder(models.Model): raise ValidationError(f"Invalid orders for bulk: {', '.join(invalid_orders)}") try: - # Import API functions only when needed - from .client.deutsche_post_international_shipping_api_client.api.bulk_orders import create_bulk_order - client = self.deutschepost_orders.first().get_api_client() + config = SiteConfiguration.get_solo() - # For now, simulate bulk order creation - # TODO: Implement actual API call - self.bulk_order_id = f"BULK-{self.id}-{timezone.now().strftime('%Y%m%d%H%M%S')}" - self.status = self.STATUS.PROCESSING - self.metadata = { - 'simulated': True, - 'bulk_order_type': self.bulk_order_type, - 'order_count': self.deutschepost_orders.count(), - 'created_at': timezone.now().isoformat() - } - self.last_error = "" + # Calculate totals from all orders + total_count = self.deutschepost_orders.count() + total_weight = sum(order.shipment_gross_weight / 1000.0 for order in self.deutschepost_orders.all()) # Convert grams to kg + + if total_count == 0: + raise ValidationError("No orders found for bulk order") + + # Use first order's product type and service level (assuming all are same type) + first_order = self.deutschepost_orders.first() + + # Create mixed bag order data + mixed_bag_order = MixedBagOrderDTO( + contact_name=config.deutschepost_contact_name or "Contact", + product=first_order.product_type, + service_level=first_order.service_level, + items_count=total_count, + items_weight_in_kilogram=max(0.1, total_weight), # Minimum 0.1kg + total_count_receptacles=total_count, # Each order is one receptacle + format_="MIXED", + telephone_number=config.deutschepost_contact_phone, + job_reference=self.description or f"Bulk-{self.id}", + customer_ekp=config.deutschepost_customer_ekp or "1234567890" + ) + + # Get customer EKP + customer_ekp = config.deutschepost_customer_ekp or "1234567890" + + # Make API call using mixed order endpoint + response = create_mixed_order.sync_detailed( + client=client, + customer_ekp=customer_ekp, + body=mixed_bag_order + ) + + if response.parsed is None: + raise ValidationError(f"Failed to create bulk order. Status: {response.status_code}") + + # Handle different response types + if hasattr(response.parsed, 'order_id'): + # Successful response (BulkOrderDto) + bulk_response = response.parsed + self.bulk_order_id = str(bulk_response.order_id) + self.status = self.STATUS.PROCESSING + self.metadata = { + 'api_response': bulk_response.to_dict() if hasattr(bulk_response, 'to_dict') else str(bulk_response), + 'bulk_order_type': self.bulk_order_type, + 'order_count': total_count, + 'total_weight_kg': total_weight, + 'created_at': timezone.now().isoformat() + } + self.last_error = "" + else: + # Error response + error_info = response.parsed.to_dict() if hasattr(response.parsed, 'to_dict') else str(response.parsed) + self.status = self.STATUS.ERROR + self.last_error = f"Bulk order creation failed: {error_info}" + self.metadata = {'api_error': error_info} + raise ValidationError(f"Deutsche Post Bulk API error: {error_info}") + self.save() + except UnexpectedStatus as e: + self.status = self.STATUS.ERROR + self.last_error = f"API Error {e.status_code}: {e.content}" + self.save() + raise ValidationError(f"Deutsche Post Bulk API error {e.status_code}: {e.content}") except Exception as e: self.status = self.STATUS.ERROR self.last_error = str(e) @@ -369,26 +860,109 @@ class DeutschePostBulkOrder(models.Model): raise ValidationError("Deutsche Post API client is not available") try: - # Import API functions only when needed - from .client.deutsche_post_international_shipping_api_client.api.bulk_orders import cancel_bulk_order - client = self.deutschepost_orders.first().get_api_client() - # For now, simulate bulk order cancellation - # TODO: Implement actual API call + # Note: Deutsche Post API might not have a direct cancel endpoint for bulk orders + # In that case, we check the current status and mark as cancelled locally + try: + # Try to get current bulk order status + response = get_bulk_order.sync_detailed( + client=client, + bulk_order_id=self.bulk_order_id + ) + + if response.parsed and hasattr(response.parsed, 'order_status'): + api_status = str(response.parsed.order_status) + if api_status in ['COMPLETED', 'FINALIZED']: + raise ValidationError("Cannot cancel bulk order that is already completed") + + except UnexpectedStatus: + # If we can't get status, proceed with local cancellation + pass + + # Since there might not be a cancel API endpoint, mark as cancelled locally self.status = self.STATUS.ERROR # Use ERROR status for cancelled self.metadata.update({ 'cancelled': True, - 'cancelled_at': timezone.now().isoformat() + 'cancelled_at': timezone.now().isoformat(), + 'cancellation_note': 'Cancelled locally - API might not support bulk order cancellation' }) self.last_error = "Bulk order cancelled by user" self.save() + except UnexpectedStatus as e: + self.last_error = f"API Error {e.status_code}: {e.content}" + self.save() + raise ValidationError(f"Deutsche Post Bulk API error {e.status_code}: {e.content}") except Exception as e: self.last_error = str(e) self.save() raise ValidationError(f"Deutsche Post Bulk API error: {str(e)}") + def refresh_bulk_status(self): + """Refresh bulk order status from Deutsche Post API.""" + if not self.bulk_order_id: + return + + if not DEUTSCHE_POST_CLIENT_AVAILABLE: + return + + try: + client = self.deutschepost_orders.first().get_api_client() + + # Make API call to get current bulk order status + response = get_bulk_order.sync_detailed( + client=client, + bulk_order_id=self.bulk_order_id + ) + + if response.parsed is None: + self.last_error = f"Failed to refresh bulk status. Status: {response.status_code}" + self.save() + return + + # Handle successful response + if hasattr(response.parsed, 'order_status'): + api_status = str(response.parsed.order_status) + if api_status in ['COMPLETED', 'FINALIZED']: + self.status = self.STATUS.COMPLETED + elif api_status in ['PROCESSING', 'IN_PROGRESS']: + self.status = self.STATUS.PROCESSING + elif api_status in ['CANCELLED', 'ERROR']: + self.status = self.STATUS.ERROR + + self.metadata.update({ + 'status_refreshed': True, + 'last_refresh': timezone.now().isoformat(), + 'api_response': response.parsed.to_dict() if hasattr(response.parsed, 'to_dict') else str(response.parsed) + }) + self.last_error = "" + else: + # Error response + error_info = response.parsed.to_dict() if hasattr(response.parsed, 'to_dict') else str(response.parsed) + self.last_error = f"Status refresh failed: {error_info}" + + self.save() + + except UnexpectedStatus as e: + self.last_error = f"API Error {e.status_code}: {e.content}" + self.save() + except Exception as e: + self.last_error = str(e) + self.save() + + def get_total_weight_kg(self): + """Get total weight of all orders in kg.""" + return sum(order.shipment_gross_weight / 1000.0 for order in self.deutschepost_orders.all()) + + def can_be_cancelled(self): + """Check if bulk order can be cancelled.""" + return ( + self.bulk_order_id and + self.status in [self.STATUS.CREATED, self.STATUS.PROCESSING] and + not self.last_error + ) + def get_tracking_url(self): """Get tracking URL for bulk order if available.""" if self.bulk_order_id: @@ -402,5 +976,74 @@ class DeutschePostBulkOrder(models.Model): return f"https://www.deutschepost.de/de/b/bulk-status.html?bulk_id={self.bulk_order_id}" return None + def save(self, *args, **kwargs): + # Auto-create remote bulk order when first saved with orders + is_creating = not self.pk + super().save(*args, **kwargs) + + if is_creating and not self.bulk_order_id and self.deutschepost_orders.exists(): + try: + self.create_remote_bulk_order() + except ValidationError: + # Don't auto-create if validation fails - admin can fix and retry manually + pass + + def delete(self, *args, **kwargs): + """Override delete to cancel remote bulk order if possible.""" + if self.bulk_order_id and self.can_be_cancelled(): + try: + self.cancel_bulk_order() + except ValidationError: + # Log error but don't prevent deletion + self.last_error = "Failed to cancel remote bulk order during deletion" + self.save() + + super().delete(*args, **kwargs) + + def generate_bulk_labels(self): + """Generate combined PDF with all order labels using HTML template.""" + if not self.deutschepost_orders.exists(): + raise ValidationError("No orders in bulk shipment") + + from django.template.loader import render_to_string + from django.core.files.base import ContentFile + from weasyprint import HTML + import io + + orders = self.deutschepost_orders.all() + total_weight_kg = self.get_total_weight_kg() + + # Prepare template context + context = { + 'bulk_order': self, + 'orders': orders, + 'total_weight_kg': total_weight_kg, + 'now': timezone.now() + } + + # Render HTML template + html_string = render_to_string('deutschepost/bulk_labels.html', context) + + # Generate PDF from HTML + pdf_buffer = io.BytesIO() + HTML(string=html_string).write_pdf(pdf_buffer) + pdf_data = pdf_buffer.getvalue() + pdf_buffer.close() + + # Save bulk label PDF + filename = f"deutschepost_bulk_labels_{self.bulk_order_id or self.id}_{timezone.now().strftime('%Y%m%d_%H%M%S')}.pdf" + self.bulk_label_pdf.save( + filename, + ContentFile(pdf_data), + save=True + ) + + self.metadata.update({ + 'bulk_labels_generated': True, + 'labels_generated_at': timezone.now().isoformat() + }) + + return True + def __str__(self): return f"Deutsche Post Bulk Order {self.bulk_order_id or 'Not Created'} - {self.deutschepost_orders.count()} orders - {self.status}" diff --git a/backend/thirdparty/deutschepost/serializers.py b/backend/thirdparty/deutschepost/serializers.py index e331632..6c6f4fc 100644 --- a/backend/thirdparty/deutschepost/serializers.py +++ b/backend/thirdparty/deutschepost/serializers.py @@ -5,13 +5,19 @@ from .models import DeutschePostOrder, DeutschePostBulkOrder class DeutschePostOrderSerializer(serializers.ModelSerializer): - commerce_order_id = serializers.IntegerField(write_only=True, required=False) state_display = serializers.CharField(source='get_state_display', read_only=True) + label_size_display = serializers.CharField(source='get_label_size_display', read_only=True) + tracking_url = serializers.SerializerMethodField(read_only=True) + estimated_delivery_days = serializers.SerializerMethodField(read_only=True) + shipping_cost_estimate = serializers.SerializerMethodField(read_only=True) + can_be_finalized = serializers.SerializerMethodField(read_only=True) + can_be_cancelled = serializers.SerializerMethodField(read_only=True) + is_trackable = serializers.SerializerMethodField(read_only=True) class Meta: model = DeutschePostOrder fields = [ - 'id', 'created_at', 'state', 'state_display', 'commerce_order', 'commerce_order_id', + 'id', 'created_at', 'state', 'state_display', 'order_id', 'customer_ekp', 'recipient_name', 'recipient_phone', 'recipient_email', 'address_line1', 'address_line2', 'address_line3', @@ -20,31 +26,33 @@ class DeutschePostOrderSerializer(serializers.ModelSerializer): 'shipment_amount', 'shipment_currency', 'sender_tax_id', 'importer_tax_id', 'return_item_wanted', 'cust_ref', 'awb_number', 'barcode', 'tracking_url', - 'metadata', 'last_error' + 'label_pdf', 'label_size', 'label_size_display', + 'metadata', 'last_error', + 'estimated_delivery_days', 'shipping_cost_estimate', + 'can_be_finalized', 'can_be_cancelled', 'is_trackable' ] read_only_fields = [ 'id', 'created_at', 'order_id', 'awb_number', 'barcode', - 'tracking_url', 'metadata', 'last_error' + 'tracking_url', 'metadata', 'last_error', 'label_pdf' ] - def validate_commerce_order_id(self, value): - """Validate that commerce order exists and uses Deutsche Post delivery.""" - try: - from commerce.models import Order - order = Order.objects.get(id=value) - - if not order.carrier: - raise ValidationError("Commerce order must have a carrier assigned") - - if order.carrier.shipping_method != "deutschepost": - raise ValidationError("Commerce order must use Deutsche Post delivery method") - - if order.status != Order.OrderStatus.COMPLETED: - raise ValidationError("Commerce order must be completed before creating Deutsche Post order") - - return value - except Order.DoesNotExist: - raise ValidationError("Commerce order does not exist") + def get_tracking_url(self, obj): + return obj.get_tracking_url() + + def get_estimated_delivery_days(self, obj): + return obj.get_estimated_delivery_days() + + def get_shipping_cost_estimate(self, obj): + return float(obj.get_shipping_cost_estimate()) + + def get_can_be_finalized(self, obj): + return obj.can_be_finalized() + + def get_can_be_cancelled(self, obj): + return obj.can_be_cancelled() + + def get_is_trackable(self, obj): + return obj.is_trackable() def validate_destination_country(self, value): """Validate country code format.""" @@ -60,14 +68,20 @@ class DeutschePostOrderSerializer(serializers.ModelSerializer): raise ValidationError("Shipment weight cannot exceed 30kg (30000g)") return value - def create(self, validated_data): - commerce_order_id = validated_data.pop('commerce_order_id', None) + def validate(self, attrs): + """Validate the complete order data.""" + # Check required fields for shipping + required_fields = ['recipient_name', 'address_line1', 'city', 'postal_code', 'destination_country'] + missing_fields = [field for field in required_fields if not attrs.get(field)] - if commerce_order_id: - from commerce.models import Order - validated_data['commerce_order'] = Order.objects.get(id=commerce_order_id) - - return super().create(validated_data) + if missing_fields: + raise ValidationError(f"Required fields missing: {', '.join(missing_fields)}") + + # Check contact information + if not attrs.get('recipient_email') and not attrs.get('recipient_phone'): + raise ValidationError("Either recipient email or phone is required for delivery notifications") + + return attrs class DeutschePostBulkOrderSerializer(serializers.ModelSerializer): @@ -79,8 +93,10 @@ class DeutschePostBulkOrderSerializer(serializers.ModelSerializer): ) status_display = serializers.CharField(source='get_status_display', read_only=True) orders_count = serializers.SerializerMethodField(read_only=True) + total_weight_kg = serializers.SerializerMethodField(read_only=True) tracking_url = serializers.SerializerMethodField(read_only=True) status_url = serializers.SerializerMethodField(read_only=True) + can_be_cancelled = serializers.SerializerMethodField(read_only=True) class Meta: model = DeutschePostBulkOrder @@ -88,23 +104,30 @@ class DeutschePostBulkOrderSerializer(serializers.ModelSerializer): 'id', 'created_at', 'status', 'status_display', 'bulk_order_id', 'bulk_order_type', 'description', 'deutschepost_orders', 'deutschepost_order_ids', 'orders_count', - 'tracking_url', 'status_url', + 'total_weight_kg', 'tracking_url', 'status_url', 'can_be_cancelled', + 'bulk_label_pdf', 'paperwork_pdf', 'metadata', 'last_error' ] read_only_fields = [ 'id', 'created_at', 'bulk_order_id', 'deutschepost_orders', - 'metadata', 'last_error' + 'bulk_label_pdf', 'paperwork_pdf', 'metadata', 'last_error' ] def get_orders_count(self, obj): return obj.deutschepost_orders.count() + def get_total_weight_kg(self, obj): + return obj.get_total_weight_kg() + def get_tracking_url(self, obj): return obj.get_tracking_url() def get_status_url(self, obj): return obj.get_bulk_status_url() + def get_can_be_cancelled(self, obj): + return obj.can_be_cancelled() + def validate_deutschepost_order_ids(self, value): """Validate that all orders exist and are eligible for bulk processing.""" if not value: diff --git a/backend/thirdparty/deutschepost/templates/deutschepost/bulk_labels.html b/backend/thirdparty/deutschepost/templates/deutschepost/bulk_labels.html new file mode 100644 index 0000000..2934bc3 --- /dev/null +++ b/backend/thirdparty/deutschepost/templates/deutschepost/bulk_labels.html @@ -0,0 +1,252 @@ + + + + + + Deutsche Post Bulk Shipping Labels + + + +
+ Deutsche Post - Bulk Shipment Labels +
+ +
+
+
+ Bulk Order ID: + {{ bulk_order.bulk_order_id|default:'Pending' }} +
+
+ Order Type: + {{ bulk_order.bulk_order_type }} +
+
+ Description: + {{ bulk_order.description|default:'N/A' }} +
+
+
+
+ Total Orders: + {{ orders.count }} +
+
+ Total Weight: + {{ total_weight_kg|floatformat:2 }} kg +
+
+ Created: + {{ bulk_order.created_at|date:"d.m.Y H:i" }} +
+
+
+ + {% for order in orders %} +
+
+ Order #{{ forloop.counter }} - {{ order.order_id|default:'Pending' }} + + + {{ order.service_level }} + + +
+ +
+
+
{{ order.recipient_name }}
+
{{ order.address_line1 }}
+ {% if order.address_line2 %} +
{{ order.address_line2 }}
+ {% endif %} +
{{ order.postal_code }} {{ order.city }}
+ {% if order.address_state %} +
{{ order.address_state }}
+ {% endif %} +
{{ order.destination_country }}
+ + {% if order.recipient_phone %} +
Tel: {{ order.recipient_phone }}
+ {% endif %} +
+ +
+
+ Weight: + {{ order.shipment_gross_weight }}g +
+
+ Value: + {{ order.shipment_amount }} {{ order.shipment_currency }} +
+
+ Product: + {{ order.product_type }} +
+ {% if order.awb_number %} +
+ AWB:
+ {{ order.awb_number }} +
+ {% elif order.barcode %} +
+ Barcode:
+ {{ order.barcode }} +
+ {% endif %} +
+
+
+ {% endfor %} + + + + \ No newline at end of file diff --git a/backend/thirdparty/deutschepost/templates/deutschepost/shipping_label.html b/backend/thirdparty/deutschepost/templates/deutschepost/shipping_label.html new file mode 100644 index 0000000..26705b6 --- /dev/null +++ b/backend/thirdparty/deutschepost/templates/deutschepost/shipping_label.html @@ -0,0 +1,264 @@ + + + + + + Deutsche Post Shipping Label + + + +
+ +
International Shipping Label
+
+ +
+

Informace o objednávce / Order Information

+
+ Order ID: + {{ order.order_id|default:'Pending' }} +
+
+ AWB Number: + {{ order.awb_number|default:'N/A' }} +
+
+ Barcode: + {{ order.barcode|default:'N/A' }} +
+
+ Customer Ref: + {{ order.cust_ref|default:'N/A' }} +
+
+ Service Level: + + {{ order.service_level }} + +
+
+ +
+

Příjemce / Recipient

+
+
{{ order.recipient_name }}
+
{{ order.address_line1 }}
+ {% if order.address_line2 %} +
{{ order.address_line2 }}
+ {% endif %} + {% if order.address_line3 %} +
{{ order.address_line3 }}
+ {% endif %} +
{{ order.postal_code }} {{ order.city }}
+ {% if order.address_state %} +
{{ order.address_state }}
+ {% endif %} +
{{ order.destination_country }}
+ {% if order.recipient_phone %} +
Tel: {{ order.recipient_phone }}
+ {% endif %} + {% if order.recipient_email %} +
Email: {{ order.recipient_email }}
+ {% endif %} +
+
+ +
+
+

Detaily zásilky / Shipment Details

+
+ Product: + {{ order.product_type }} +
+
+ Weight: + {{ order.shipment_gross_weight }}g +
+
+ Value: + {{ order.shipment_amount }} {{ order.shipment_currency }} +
+ {% if order.sender_tax_id %} +
+ IOSS: + {{ order.sender_tax_id }} +
+ {% endif %} +
+ +
+

Doručení / Delivery

+
+ Est. Days: + {{ estimated_delivery_days }} days +
+
+ Return: + {% if order.return_item_wanted %}Yes{% else %}No{% endif %} +
+
+ Created: + {{ order.created_at|date:"d.m.Y H:i" }} +
+
+
+ + {% if order.awb_number or order.barcode %} +
+
Tracking Number / Sledovací číslo
+
{{ order.awb_number|default:order.barcode }}
+
+ Track at: https://www.deutschepost.de/de/sendungsverfolgung.html +
+
+ {% endif %} + +
+ Generated: {{ now|date:"d.m.Y H:i:s" }} | Deutsche Post International Shipping API +
+ + \ No newline at end of file diff --git a/backend/thirdparty/deutschepost/views.py b/backend/thirdparty/deutschepost/views.py index 5bf1344..cdee642 100644 --- a/backend/thirdparty/deutschepost/views.py +++ b/backend/thirdparty/deutschepost/views.py @@ -31,11 +31,48 @@ from .serializers import ( description="Returns detailed information for a single Deutsche Post order including tracking data. Orders are managed through Carrier model.", responses=DeutschePostOrderSerializer, ), + create=extend_schema( + tags=["deutschepost"], + summary="Create Deutsche Post Order", + description="Create a new Deutsche Post order with recipient and shipment details. Order will automatically be created remotely if validation passes.", + request=DeutschePostOrderSerializer, + responses={ + 201: DeutschePostOrderSerializer, + 400: OpenApiResponse(description="Validation error") + }, + ), + update=extend_schema( + tags=["deutschepost"], + summary="Update Deutsche Post Order", + description="Update Deutsche Post order details. Cannot update orders that are already created remotely.", + request=DeutschePostOrderSerializer, + responses={ + 200: DeutschePostOrderSerializer, + 400: OpenApiResponse(description="Validation error or order already remote") + }, + ), + partial_update=extend_schema( + tags=["deutschepost"], + summary="Partially Update Deutsche Post Order", + description="Partially update Deutsche Post order details. Cannot update orders that are already created remotely.", + request=DeutschePostOrderSerializer, + responses={ + 200: DeutschePostOrderSerializer, + 400: OpenApiResponse(description="Validation error or order already remote") + }, + ), + destroy=extend_schema( + tags=["deutschepost"], + summary="Delete Deutsche Post Order", + description="Delete Deutsche Post order. If order is created remotely, it will attempt to cancel it first.", + responses={ + 204: OpenApiResponse(description="Order deleted successfully"), + 400: OpenApiResponse(description="Cannot delete order") + }, + ), ) class DeutschePostOrderViewSet( - mixins.ListModelMixin, - mixins.RetrieveModelMixin, - viewsets.GenericViewSet + viewsets.ModelViewSet # Changed to ModelViewSet for full CRUD ): queryset = DeutschePostOrder.objects.all().order_by("-created_at") serializer_class = DeutschePostOrderSerializer @@ -112,7 +149,7 @@ class DeutschePostOrderViewSet( order = self.get_object() try: - order.cancel_order() + order.cancel_remote_order() serializer = self.get_serializer(order) return Response(serializer.data) except Exception as e: @@ -121,6 +158,83 @@ class DeutschePostOrderViewSet( status=status.HTTP_400_BAD_REQUEST ) + @extend_schema( + tags=["deutschepost"], + summary="Finalize Deutsche Post Order", + description=""" + Finalize the order in Deutsche Post API system. This will: + - Call Deutsche Post API to finalize the order + - Update the order state to FINALIZED + - Retrieve AWB number and barcode for tracking + - Enable label generation + - Required before the order can be shipped + """, + request=None, + responses={ + 200: DeutschePostOrderSerializer, + 400: OpenApiResponse(description="Order cannot be finalized or API error"), + 500: OpenApiResponse(description="Deutsche Post API error") + } + ) + @action(detail=True, methods=['post']) + def finalize_order(self, request, pk=None): + """Finalize order in Deutsche Post API.""" + order = self.get_object() + + try: + order.finalize_remote_order() + serializer = self.get_serializer(order) + return Response(serializer.data) + except Exception as e: + return Response( + {'error': str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) + + @extend_schema( + tags=["deutschepost"], + summary="Generate Shipping Label", + description=""" + Generate and download shipping label PDF. This will: + - Attempt to download label from Deutsche Post API + - If API label not available, generate simple label with order details + - Save label PDF to order.label_pdf field + - Include barcode and tracking information + - Requires order to be created remotely first + """, + request=None, + responses={ + 200: OpenApiResponse(description="Label generated successfully", examples={ + "application/json": { + "message": "Label generated successfully", + "label_url": "/media/deutschepost/labels/label_123.pdf" + } + }), + 400: OpenApiResponse(description="Order not ready for label generation or API error"), + 500: OpenApiResponse(description="Deutsche Post API error") + } + ) + @action(detail=True, methods=['post']) + def generate_label(self, request, pk=None): + """Generate shipping label PDF.""" + order = self.get_object() + + try: + order.generate_shipping_label() + + label_url = order.label_pdf.url if order.label_pdf else None + return Response({ + 'message': 'Label generated successfully', + 'label_url': label_url, + 'order_id': order.order_id, + 'awb_number': order.awb_number + }) + except Exception as e: + return Response( + {'error': str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) + @extend_schema( tags=["deutschepost"], summary="Get Tracking URL", @@ -298,3 +412,78 @@ class DeutschePostBulkOrderViewSet( {'error': 'Bulk order not created remotely yet'}, status=status.HTTP_404_NOT_FOUND ) + + @extend_schema( + tags=["deutschepost"], + summary="Refresh Bulk Order Status", + description=""" + Refresh bulk order status from Deutsche Post API. This will: + - Fetch latest bulk order status from Deutsche Post + - Update bulk order status (PROCESSING, COMPLETED, etc.) + - Store raw API response data in metadata + - Update individual order statuses if needed + """, + request=None, + responses={ + 200: DeutschePostBulkOrderSerializer, + 400: OpenApiResponse(description="Bulk order not created remotely"), + 500: OpenApiResponse(description="Deutsche Post API error") + } + ) + @action(detail=True, methods=['post']) + def refresh_status(self, request, pk=None): + """Refresh bulk order status from Deutsche Post API.""" + bulk_order = self.get_object() + + try: + bulk_order.refresh_bulk_status() + serializer = self.get_serializer(bulk_order) + return Response(serializer.data) + except Exception as e: + return Response( + {'error': str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) + + @extend_schema( + tags=["deutschepost"], + summary="Generate Bulk Labels", + description=""" + Generate combined PDF with all order labels for bulk shipment. This will: + - Create multi-page PDF with all order details + - Include recipient addresses and tracking information + - Format for warehouse processing + - Save PDF to bulk_order.bulk_label_pdf field + """, + request=None, + responses={ + 200: OpenApiResponse(description="Bulk labels generated successfully", examples={ + "application/json": { + "message": "Bulk labels generated successfully", + "labels_url": "/media/deutschepost/bulk_labels/bulk_labels_123.pdf", + "orders_count": 5 + } + }), + 400: OpenApiResponse(description="No orders in bulk shipment or generation error"), + } + ) + @action(detail=True, methods=['post']) + def generate_labels(self, request, pk=None): + """Generate bulk shipping labels PDF.""" + bulk_order = self.get_object() + + try: + bulk_order.generate_bulk_labels() + + labels_url = bulk_order.bulk_label_pdf.url if bulk_order.bulk_label_pdf else None + return Response({ + 'message': 'Bulk labels generated successfully', + 'labels_url': labels_url, + 'bulk_order_id': bulk_order.bulk_order_id, + 'orders_count': bulk_order.deutschepost_orders.count() + }) + except Exception as e: + return Response( + {'error': str(e)}, + status=status.HTTP_400_BAD_REQUEST + )