karrio-server-documents 2025.5.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. karrio/server/documents/__init__.py +0 -0
  2. karrio/server/documents/admin.py +93 -0
  3. karrio/server/documents/apps.py +13 -0
  4. karrio/server/documents/filters.py +14 -0
  5. karrio/server/documents/generator.py +298 -0
  6. karrio/server/documents/migrations/0001_initial.py +69 -0
  7. karrio/server/documents/migrations/0002_alter_documenttemplate_related_objects.py +18 -0
  8. karrio/server/documents/migrations/0003_rename_related_objects_documenttemplate_related_object.py +18 -0
  9. karrio/server/documents/migrations/0004_documenttemplate_active.py +18 -0
  10. karrio/server/documents/migrations/0005_alter_documenttemplate_description_and_more.py +23 -0
  11. karrio/server/documents/migrations/0006_documenttemplate_metadata.py +25 -0
  12. karrio/server/documents/migrations/0007_alter_documenttemplate_related_object.py +18 -0
  13. karrio/server/documents/migrations/0008_documenttemplate_options.py +26 -0
  14. karrio/server/documents/migrations/__init__.py +0 -0
  15. karrio/server/documents/models.py +61 -0
  16. karrio/server/documents/serializers/__init__.py +4 -0
  17. karrio/server/documents/serializers/base.py +89 -0
  18. karrio/server/documents/serializers/documents.py +9 -0
  19. karrio/server/documents/signals.py +29 -0
  20. karrio/server/documents/tests/__init__.py +6 -0
  21. karrio/server/documents/tests/test_generator.py +474 -0
  22. karrio/server/documents/tests/test_templates.py +318 -0
  23. karrio/server/documents/tests.py +182 -0
  24. karrio/server/documents/urls.py +11 -0
  25. karrio/server/documents/utils.py +2486 -0
  26. karrio/server/documents/views/__init__.py +0 -0
  27. karrio/server/documents/views/printers.py +223 -0
  28. karrio/server/documents/views/templates.py +255 -0
  29. karrio/server/graph/schemas/__init__.py +1 -0
  30. karrio/server/graph/schemas/documents/__init__.py +46 -0
  31. karrio/server/graph/schemas/documents/inputs.py +41 -0
  32. karrio/server/graph/schemas/documents/mutations.py +51 -0
  33. karrio/server/graph/schemas/documents/types.py +45 -0
  34. karrio_server_documents-2025.5.2.dist-info/METADATA +19 -0
  35. karrio_server_documents-2025.5.2.dist-info/RECORD +37 -0
  36. karrio_server_documents-2025.5.2.dist-info/WHEEL +5 -0
  37. karrio_server_documents-2025.5.2.dist-info/top_level.txt +2 -0
@@ -0,0 +1,61 @@
1
+ import functools
2
+ import django.urls as urls
3
+ from django.db import models
4
+ from django.core.validators import RegexValidator
5
+
6
+ import karrio.server.core.models as core
7
+
8
+
9
+ @core.register_model
10
+ class DocumentTemplate(core.OwnedEntity):
11
+ class Meta:
12
+ db_table = "document-template"
13
+ verbose_name = "Document Template"
14
+ verbose_name_plural = "Document Templates"
15
+ ordering = ["-created_at"]
16
+
17
+ id = models.CharField(
18
+ max_length=50,
19
+ primary_key=True,
20
+ default=functools.partial(core.uuid, prefix="doc_"),
21
+ editable=False,
22
+ )
23
+ name = models.CharField(max_length=50, db_index=True)
24
+ slug = models.SlugField(
25
+ max_length=20,
26
+ validators=[RegexValidator(r"^[a-z0-9_]+$")],
27
+ db_index=True,
28
+ )
29
+ template = models.TextField()
30
+ description = models.CharField(
31
+ max_length=50,
32
+ null=True,
33
+ blank=True,
34
+ db_index=True,
35
+ )
36
+ related_object = models.CharField(max_length=25, blank=True, null=True)
37
+ active = models.BooleanField(
38
+ default=True,
39
+ help_text="disable template flag. to filter out from active document downloads",
40
+ )
41
+ metadata = models.JSONField(
42
+ blank=True,
43
+ null=True,
44
+ default=core.field_default({}),
45
+ )
46
+ options = models.JSONField(
47
+ blank=True,
48
+ null=True,
49
+ default=core.field_default({}),
50
+ )
51
+
52
+ @property
53
+ def object_type(self):
54
+ return "document-template"
55
+
56
+ @property
57
+ def preview_url(self):
58
+ return urls.reverse(
59
+ "karrio.server.documents:templates-documents-print",
60
+ kwargs=dict(pk=self.pk, slug=self.slug),
61
+ ) + f"?{self.related_object or 'shipment'}s=sample"
@@ -0,0 +1,4 @@
1
+ from karrio.server.serializers import *
2
+ from karrio.server.core.serializers import *
3
+ from karrio.server.documents.serializers.base import *
4
+ from karrio.server.documents.serializers.documents import *
@@ -0,0 +1,89 @@
1
+ import karrio.lib as lib
2
+ import karrio.server.serializers as serializers
3
+ import karrio.server.documents.models as models
4
+ import karrio.server.core.validators as validators
5
+
6
+
7
+ class TemplateRelatedObject(lib.StrEnum):
8
+ shipment = "shipment"
9
+ order = "order"
10
+ other = "other"
11
+
12
+
13
+ class DocumentTemplateData(serializers.Serializer):
14
+ name = serializers.CharField(max_length=255, help_text="The template name")
15
+ slug = serializers.CharField(max_length=255, help_text="The template slug")
16
+ template = serializers.CharField(help_text="The template content")
17
+ active = serializers.BooleanField(default=True, help_text="disable template flag.")
18
+ description = serializers.CharField(
19
+ max_length=255, help_text="The template description", required=False
20
+ )
21
+ metadata = serializers.PlainDictField(
22
+ help_text="The template metadata", required=False
23
+ )
24
+ options = serializers.PlainDictField(
25
+ help_text="The template rendering options", required=False
26
+ )
27
+ related_object = serializers.ChoiceField(
28
+ choices=TemplateRelatedObject,
29
+ help_text="The template related object",
30
+ required=False,
31
+ default=TemplateRelatedObject.other,
32
+ )
33
+
34
+
35
+ class DocumentTemplate(serializers.EntitySerializer, DocumentTemplateData):
36
+ object_type = serializers.CharField(
37
+ default="document-template", help_text="Specifies the object type"
38
+ )
39
+ preview_url = serializers.URLField(
40
+ help_text="The template preview URL",
41
+ required=False,
42
+ )
43
+
44
+
45
+ class DocumentData(serializers.Serializer):
46
+ template_id = serializers.CharField(
47
+ help_text="The template name. **Required if template is not provided.**",
48
+ required=False,
49
+ )
50
+ template = serializers.CharField(
51
+ help_text="The template content. **Required if template_id is not provided.**",
52
+ required=False,
53
+ )
54
+ doc_format = serializers.CharField(
55
+ help_text="The format of the document",
56
+ required=False,
57
+ )
58
+ doc_name = serializers.CharField(
59
+ help_text="The file name",
60
+ required=False,
61
+ )
62
+ data = serializers.PlainDictField(
63
+ help_text="The template data",
64
+ required=False,
65
+ default={},
66
+ )
67
+ options = serializers.PlainDictField(
68
+ help_text="The template rendering options",
69
+ required=False,
70
+ )
71
+
72
+
73
+ class GeneratedDocument(serializers.Serializer):
74
+ template_id = serializers.CharField(
75
+ help_text="The template name",
76
+ required=False,
77
+ )
78
+ doc_format = serializers.CharField(
79
+ help_text="The format of the document",
80
+ required=False,
81
+ )
82
+ doc_name = serializers.CharField(
83
+ help_text="The file name",
84
+ required=False,
85
+ )
86
+ doc_file = serializers.CharField(
87
+ help_text="A base64 file content",
88
+ required=True,
89
+ )
@@ -0,0 +1,9 @@
1
+ import karrio.server.serializers as serializers
2
+ import karrio.server.documents.models as models
3
+
4
+
5
+ @serializers.owned_model_serializer
6
+ class DocumentTemplateModelSerializer(serializers.ModelSerializer):
7
+ class Meta:
8
+ model = models.DocumentTemplate
9
+ exclude = ["created_at", "updated_at", "created_by"]
@@ -0,0 +1,29 @@
1
+ from django.db.models import signals
2
+
3
+ from karrio.server.core import utils
4
+ from karrio.server.core.logging import logger
5
+ from karrio.server import serializers
6
+ import karrio.server.documents.models as models
7
+
8
+
9
+ def register_all():
10
+ signals.post_save.connect(document_updated, sender=models.DocumentTemplate)
11
+
12
+ logger.info("Signal registration complete", module="karrio.documents")
13
+
14
+
15
+ @utils.disable_for_loaddata
16
+ def document_updated(sender, instance, *args, **kwargs):
17
+ changes = kwargs.get("update_fields") or []
18
+ post_create = "created_at" in changes
19
+
20
+ if post_create:
21
+ duplicates = models.DocumentTemplate.objects.filter(
22
+ slug=instance.slug,
23
+ **({"org__id": instance.link.org.id} if hasattr(instance, "link") else {})
24
+ ).count()
25
+
26
+ if duplicates > 1:
27
+ raise serializers.ValidationError(
28
+ {"slug": "Document template with this slug already exists."}
29
+ )
@@ -0,0 +1,6 @@
1
+ import logging
2
+
3
+ logging.disable(logging.CRITICAL)
4
+
5
+ from karrio.server.documents.tests.test_templates import *
6
+ from karrio.server.documents.tests.test_generator import *
@@ -0,0 +1,474 @@
1
+ import json
2
+ import base64
3
+ from unittest.mock import ANY
4
+ from django.urls import reverse
5
+ from rest_framework import status
6
+ from karrio.server.core.tests import APITestCase
7
+ from karrio.server.core.utils import ResourceAccessToken
8
+ from karrio.server.documents.models import DocumentTemplate
9
+ import karrio.server.manager.models as manager_models
10
+ import karrio.server.orders.models as order_models
11
+
12
+
13
+ class TestDocumentGenerator(APITestCase):
14
+ def setUp(self) -> None:
15
+ super().setUp()
16
+ self.template = DocumentTemplate.objects.create(
17
+ **{
18
+ "name": "Test Generator Template",
19
+ "slug": "test_generator",
20
+ "template": SIMPLE_HTML_TEMPLATE,
21
+ "description": "A test template for generation",
22
+ "related_object": "shipment",
23
+ "metadata": {"doc_type": "invoice"},
24
+ "options": {"page_size": "A4"},
25
+ "created_by": self.user,
26
+ }
27
+ )
28
+
29
+ def test_generate_document_with_template_id(self):
30
+ """Test document generation using template_id"""
31
+ url = reverse("karrio.server.documents:document-generator")
32
+ data = {
33
+ "template_id": self.template.id,
34
+ "data": {
35
+ "title": "Test Invoice",
36
+ "shipment": {
37
+ "tracking_number": "TEST123456",
38
+ "service": "Standard",
39
+ "status": "delivered",
40
+ },
41
+ },
42
+ "doc_name": "test_invoice.pdf",
43
+ "doc_format": "PDF",
44
+ }
45
+
46
+ response = self.client.post(url, data)
47
+ response_data = json.loads(response.content)
48
+
49
+ # Debug information if test fails
50
+ if response.status_code != status.HTTP_201_CREATED:
51
+ print(f"Response status: {response.status_code}")
52
+ print(f"Response content: {response.content}")
53
+ print(f"Response data: {response_data}")
54
+
55
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
56
+ self.assertIn("doc_file", response_data)
57
+ self.assertEqual(response_data["doc_name"], "test_invoice.pdf")
58
+ self.assertEqual(response_data["doc_format"], "PDF")
59
+
60
+ # Verify the doc_file is a valid base64 string
61
+ try:
62
+ decoded_pdf = base64.b64decode(response_data["doc_file"])
63
+ self.assertGreater(len(decoded_pdf), 0)
64
+ # Basic PDF header check
65
+ self.assertTrue(decoded_pdf.startswith(b'%PDF'))
66
+ except Exception as e:
67
+ self.fail(f"Invalid base64 PDF content: {e}")
68
+
69
+ def test_generate_document_with_inline_template(self):
70
+ """Test document generation using inline template"""
71
+ url = reverse("karrio.server.documents:document-generator")
72
+ data = {
73
+ "template": SIMPLE_HTML_TEMPLATE,
74
+ "data": {
75
+ "title": "Inline Template Test",
76
+ "order": {
77
+ "order_id": "ORD-001",
78
+ "order_date": "2023-12-01",
79
+ },
80
+ },
81
+ "doc_name": "inline_test.pdf",
82
+ }
83
+
84
+ response = self.client.post(url, data)
85
+ response_data = json.loads(response.content)
86
+
87
+ # Debug information if test fails
88
+ if response.status_code != status.HTTP_201_CREATED:
89
+ print(f"Response status: {response.status_code}")
90
+ print(f"Response content: {response.content}")
91
+ print(f"Response data: {response_data}")
92
+
93
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
94
+ self.assertIn("doc_file", response_data)
95
+ self.assertEqual(response_data["doc_name"], "inline_test.pdf")
96
+
97
+ # Verify the doc_file is a valid base64 string and PDF
98
+ try:
99
+ decoded_pdf = base64.b64decode(response_data["doc_file"])
100
+ self.assertGreater(len(decoded_pdf), 0)
101
+ # Basic PDF header check
102
+ self.assertTrue(decoded_pdf.startswith(b'%PDF'))
103
+ except Exception as e:
104
+ self.fail(f"Invalid base64 PDF content: {e}")
105
+
106
+ def test_generate_document_missing_template(self):
107
+ """Test error when neither template nor template_id is provided"""
108
+ url = reverse("karrio.server.documents:document-generator")
109
+ data = {
110
+ "data": {"title": "No Template Test"},
111
+ "doc_name": "no_template.pdf",
112
+ }
113
+
114
+ response = self.client.post(url, data)
115
+
116
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
117
+ self.assertIn("template or template_id is required", str(response.content))
118
+
119
+ def test_generate_document_with_metadata_and_options(self):
120
+ """Test document generation with metadata and options"""
121
+ url = reverse("karrio.server.documents:document-generator")
122
+ data = {
123
+ "template_id": self.template.id,
124
+ "data": {
125
+ "title": "Metadata Test",
126
+ "shipment": {"tracking_number": "META123"},
127
+ },
128
+ "options": {
129
+ "prefetch": {
130
+ "current_date": "{{ utils.datetime.now().strftime('%Y-%m-%d') }}",
131
+ "tracking_url": "https://track.example.com/{{ shipment.tracking_number }}",
132
+ }
133
+ },
134
+ }
135
+
136
+ response = self.client.post(url, data)
137
+ response_data = json.loads(response.content)
138
+
139
+ # Debug information if test fails
140
+ if response.status_code != status.HTTP_201_CREATED:
141
+ print(f"Response status: {response.status_code}")
142
+ print(f"Response content: {response.content}")
143
+ print(f"Response data: {response_data}")
144
+
145
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
146
+ self.assertIn("doc_file", response_data)
147
+
148
+ # Verify the doc_file is a valid base64 string and PDF
149
+ try:
150
+ decoded_pdf = base64.b64decode(response_data["doc_file"])
151
+ self.assertGreater(len(decoded_pdf), 0)
152
+ # Basic PDF header check
153
+ self.assertTrue(decoded_pdf.startswith(b'%PDF'))
154
+ except Exception as e:
155
+ self.fail(f"Invalid base64 PDF content: {e}")
156
+
157
+
158
+ class TestDocumentPrinters(APITestCase):
159
+ def setUp(self) -> None:
160
+ super().setUp()
161
+ self.template = DocumentTemplate.objects.create(
162
+ **{
163
+ "name": "Printer Test Template",
164
+ "slug": "printer_test",
165
+ "template": SIMPLE_HTML_TEMPLATE,
166
+ "description": "A test template for printing",
167
+ "related_object": "shipment",
168
+ "created_by": self.user,
169
+ }
170
+ )
171
+
172
+ def test_template_docs_printer(self):
173
+ """Test template document printing endpoint"""
174
+ token = str(
175
+ ResourceAccessToken.for_resource(
176
+ user=self.user,
177
+ resource_type="template",
178
+ resource_ids=[self.template.id],
179
+ access=["render"],
180
+ )
181
+ )
182
+ url = f"/documents/templates/{self.template.id}.{self.template.slug}"
183
+ response = self.client.get(url, {"title": "Printed Document", "token": token})
184
+
185
+ # Debug information if test fails
186
+ if response.status_code != status.HTTP_200_OK:
187
+ print(f"Response status: {response.status_code}")
188
+ print(f"Response content: {response.content}")
189
+
190
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
191
+ self.assertTrue(response["Content-Type"].startswith("application/pdf"))
192
+
193
+ # Verify we got a valid PDF - handle streaming content
194
+ if hasattr(response, 'streaming_content'):
195
+ content = b''.join(response.streaming_content)
196
+ else:
197
+ content = response.content
198
+ self.assertGreater(len(content), 0)
199
+ self.assertTrue(content.startswith(b'%PDF'))
200
+
201
+ def test_template_docs_printer_with_download(self):
202
+ """Test template document printing with download flag"""
203
+ token = str(
204
+ ResourceAccessToken.for_resource(
205
+ user=self.user,
206
+ resource_type="template",
207
+ resource_ids=[self.template.id],
208
+ access=["render"],
209
+ )
210
+ )
211
+ url = f"/documents/templates/{self.template.id}.{self.template.slug}"
212
+ response = self.client.get(
213
+ url, {"download": "true", "title": "Download Test", "token": token}
214
+ )
215
+
216
+ # Debug information if test fails
217
+ if response.status_code != status.HTTP_200_OK:
218
+ print(f"Response status: {response.status_code}")
219
+ if hasattr(response, 'content'):
220
+ print(f"Response content: {response.content}")
221
+
222
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
223
+ self.assertIn("attachment", response.get("Content-Disposition", ""))
224
+
225
+ # Verify we got a valid PDF - handle streaming content
226
+ if hasattr(response, 'streaming_content'):
227
+ content = b''.join(response.streaming_content)
228
+ else:
229
+ content = response.content
230
+ self.assertGreater(len(content), 0)
231
+ self.assertTrue(content.startswith(b'%PDF'))
232
+
233
+
234
+ class TestDocumentGeneratorIntegration(APITestCase):
235
+ """Integration tests for the document generator with real data"""
236
+
237
+ def setUp(self) -> None:
238
+ super().setUp()
239
+ self.invoice_template = DocumentTemplate.objects.create(
240
+ **{
241
+ "name": "Invoice Template",
242
+ "slug": "invoice",
243
+ "template": INVOICE_HTML_TEMPLATE,
244
+ "description": "Invoice template for testing",
245
+ "related_object": "shipment",
246
+ "metadata": {"doc_type": "commercial_invoice"},
247
+ "created_by": self.user,
248
+ }
249
+ )
250
+
251
+ def test_shipment_context_generation(self):
252
+ """Test document generation with shipment context"""
253
+ # Create address instances
254
+ recipient = manager_models.Address.objects.create(
255
+ person_name="John Doe",
256
+ address_line1="123 Main St",
257
+ city="Test City",
258
+ country_code="CA",
259
+ postal_code="12345",
260
+ created_by=self.user,
261
+ )
262
+
263
+ shipper = manager_models.Address.objects.create(
264
+ person_name="Shipper Inc",
265
+ address_line1="456 Business Ave",
266
+ city="Ship City",
267
+ country_code="CA",
268
+ postal_code="67890",
269
+ created_by=self.user,
270
+ )
271
+
272
+ # Create a test shipment
273
+ shipment = manager_models.Shipment.objects.create(
274
+ recipient=recipient,
275
+ shipper=shipper,
276
+ test_mode=True,
277
+ status="shipped",
278
+ tracking_number="TRACK123456",
279
+ selected_rate={
280
+ "service": "express",
281
+ "carrier_id": "test_carrier",
282
+ "carrier_name": "Test Carrier",
283
+ "currency": "USD",
284
+ "total_charge": 10.00,
285
+ "test_mode": True,
286
+ },
287
+ created_by=self.user,
288
+ )
289
+
290
+ # Create and set parcels
291
+ parcel = manager_models.Parcel.objects.create(
292
+ weight=2.5,
293
+ weight_unit="KG",
294
+ dimension_unit="CM",
295
+ width=20,
296
+ height=15,
297
+ length=30,
298
+ created_by=self.user,
299
+ )
300
+ shipment.parcels.set([parcel])
301
+
302
+ url = reverse("karrio.server.documents:document-generator")
303
+ data = {
304
+ "template_id": self.invoice_template.id,
305
+ "data": {"shipments": str(shipment.id)},
306
+ "doc_name": "shipment_invoice.pdf",
307
+ }
308
+
309
+ response = self.client.post(url, data)
310
+ response_data = json.loads(response.content)
311
+
312
+ # Debug information if test fails
313
+ if response.status_code != status.HTTP_201_CREATED:
314
+ print(f"Response status: {response.status_code}")
315
+ print(f"Response content: {response.content}")
316
+ print(f"Response data: {response_data}")
317
+
318
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
319
+ self.assertIn("doc_file", response_data)
320
+ self.assertEqual(response_data["doc_name"], "shipment_invoice.pdf")
321
+
322
+ # Verify the doc_file is a valid base64 string and PDF
323
+ try:
324
+ decoded_pdf = base64.b64decode(response_data["doc_file"])
325
+ self.assertGreater(len(decoded_pdf), 0)
326
+ # Basic PDF header check
327
+ self.assertTrue(decoded_pdf.startswith(b'%PDF'))
328
+ except Exception as e:
329
+ self.fail(f"Invalid base64 PDF content: {e}")
330
+
331
+
332
+ # Test Data and Fixtures
333
+ SIMPLE_HTML_TEMPLATE = """
334
+ <!DOCTYPE html>
335
+ <html>
336
+ <head>
337
+ <title>{{ title | default('Simple Test Document') }}</title>
338
+ <style>
339
+ body { font-family: Arial, sans-serif; margin: 20px; }
340
+ .header { text-align: center; border-bottom: 2px solid #333; padding-bottom: 10px; }
341
+ .content { margin: 20px 0; }
342
+ </style>
343
+ </head>
344
+ <body>
345
+ <div class="header">
346
+ <h1>{{ title | default('Simple Test Document') }}</h1>
347
+ </div>
348
+
349
+ <div class="content">
350
+ {% if shipment %}
351
+ <p><strong>Tracking Number:</strong> {{ shipment.tracking_number }}</p>
352
+ <p><strong>Service:</strong> {{ shipment.service }}</p>
353
+ <p><strong>Status:</strong> {{ shipment.status }}</p>
354
+ {% endif %}
355
+
356
+ {% if order %}
357
+ <p><strong>Order ID:</strong> {{ order.order_id }}</p>
358
+ <p><strong>Order Date:</strong> {{ order.order_date }}</p>
359
+ {% endif %}
360
+
361
+ <p>Generated at: {{ utils.datetime.now().strftime('%Y-%m-%d %H:%M:%S') }}</p>
362
+ </div>
363
+ </body>
364
+ </html>
365
+ """
366
+
367
+ INVOICE_HTML_TEMPLATE = """
368
+ <!DOCTYPE html>
369
+ <html>
370
+ <head>
371
+ <title>Commercial Invoice</title>
372
+ <style>
373
+ body { font-family: Arial, sans-serif; margin: 0; padding: 20px; }
374
+ .header { text-align: center; margin-bottom: 30px; }
375
+ .addresses { display: table; width: 100%; margin-bottom: 30px; }
376
+ .address { display: table-cell; width: 50%; vertical-align: top; padding: 10px; }
377
+ .invoice-details { margin-bottom: 30px; }
378
+ table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
379
+ th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
380
+ th { background-color: #f2f2f2; }
381
+ .total { text-align: right; font-weight: bold; }
382
+ </style>
383
+ </head>
384
+ <body>
385
+ <div class="header">
386
+ <h1>COMMERCIAL INVOICE</h1>
387
+ {% if metadata and metadata.doc_type %}
388
+ <p>Document Type: {{ metadata.doc_type }}</p>
389
+ {% endif %}
390
+ </div>
391
+
392
+ <div class="addresses">
393
+ {% if shipment %}
394
+ <div class="address">
395
+ <h3>Ship From:</h3>
396
+ <p>{{ shipment.shipper.person_name or shipment.shipper.company_name }}</p>
397
+ <p>{{ shipment.shipper.address_line1 }}</p>
398
+ {% if shipment.shipper.address_line2 %}
399
+ <p>{{ shipment.shipper.address_line2 }}</p>
400
+ {% endif %}
401
+ <p>{{ shipment.shipper.city }}, {{ shipment.shipper.state_code }} {{ shipment.shipper.postal_code }}</p>
402
+ <p>{{ shipment.shipper.country_code }}</p>
403
+ </div>
404
+
405
+ <div class="address">
406
+ <h3>Ship To:</h3>
407
+ <p>{{ shipment.recipient.person_name or shipment.recipient.company_name }}</p>
408
+ <p>{{ shipment.recipient.address_line1 }}</p>
409
+ {% if shipment.recipient.address_line2 %}
410
+ <p>{{ shipment.recipient.address_line2 }}</p>
411
+ {% endif %}
412
+ <p>{{ shipment.recipient.city }}, {{ shipment.recipient.state_code }} {{ shipment.recipient.postal_code }}</p>
413
+ <p>{{ shipment.recipient.country_code }}</p>
414
+ </div>
415
+ {% endif %}
416
+ </div>
417
+
418
+ {% if shipment %}
419
+ <div class="invoice-details">
420
+ <table>
421
+ <tr>
422
+ <th>Tracking Number</th>
423
+ <td>{{ shipment.tracking_number }}</td>
424
+ <th>Service</th>
425
+ <td>{{ shipment.service }}</td>
426
+ </tr>
427
+ <tr>
428
+ <th>Status</th>
429
+ <td>{{ shipment.status }}</td>
430
+ <th>Created Date</th>
431
+ <td>{{ shipment.created_at }}</td>
432
+ </tr>
433
+ </table>
434
+ </div>
435
+ {% endif %}
436
+
437
+ {% if line_items %}
438
+ <h3>Items</h3>
439
+ <table>
440
+ <thead>
441
+ <tr>
442
+ <th>SKU</th>
443
+ <th>Description</th>
444
+ <th>Quantity</th>
445
+ <th>Unit Price</th>
446
+ <th>Total</th>
447
+ </tr>
448
+ </thead>
449
+ <tbody>
450
+ {% for item in line_items %}
451
+ <tr>
452
+ <td>{{ item.sku }}</td>
453
+ <td>{{ item.title }}</td>
454
+ <td>{{ item.quantity }}</td>
455
+ <td>${{ "%.2f"|format(item.unit_price|float) }}</td>
456
+ <td>${{ "%.2f"|format((item.unit_price|float) * (item.quantity|int)) }}</td>
457
+ </tr>
458
+ {% endfor %}
459
+ </tbody>
460
+ </table>
461
+ {% endif %}
462
+
463
+ <p style="text-align: center; margin-top: 40px;">
464
+ Generated by Karrio on {{ utils.datetime.now().strftime('%Y-%m-%d') }}
465
+ </p>
466
+ </body>
467
+ </html>
468
+ """
469
+
470
+ # Sample base64 encoded PDF (minimal valid PDF)
471
+ SAMPLE_PDF_BASE64 = "JVBERi0xLjQKMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwovUGFnZXMgMiAwIFIKPj4KZW5kb2JqCjIgMCBvYmoKPDwKL1R5cGUgL1BhZ2VzCi9LaWRzIFszIDAgUl0KL0NvdW50IDEKPD4KZW5kb2JqCjMgMCBvYmoKPDwKL1R5cGUgL1BhZ2UKL1BhcmVudCAyIDAgUgovTWVkaWFCb3ggWzAgMCA2MTIgNzkyXQo+PgplbmRvYmoKeHJlZgowIDQKMDAwMDAwMDAwMCA2NTUzNSBmCjAwMDAwMDAwMDkgMDAwMDAgbgowMDAwMDAwMDU4IDAwMDAwIG4KMDAwMDAwMDExNSAwMDAwMCBuCnRyYWlsZXIKPDwKL1NpemUgNAovUm9vdCAxIDAgUgo+PgpzdGFydHhyZWYKMTc4CiUlRU9G"
472
+
473
+ # Sample base64 encoded ZIP file (for testing multi-document bundling)
474
+ SAMPLE_ZIP_BASE64 = """UEsDBBQAAAAIAKOOdU8e6yR4HQAAAB8AAAANAAAAdGVzdC1kb2MxLnBkZitIQ1BQ0KdJI5tT37IM5rK/ZIHYsyeCOYhNnwKNOkKjDqN9rCE9C/qzlvggwAEw2TnZ+UpJOZn5eUrJOcY5QCOjkWRzQpOy"""