karrio-server-documents 2025.5rc1__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.

Potentially problematic release.


This version of karrio-server-documents might be problematic. Click here for more details.

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