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.
- karrio/server/documents/__init__.py +0 -0
- karrio/server/documents/admin.py +93 -0
- karrio/server/documents/apps.py +13 -0
- karrio/server/documents/filters.py +14 -0
- karrio/server/documents/generator.py +298 -0
- karrio/server/documents/migrations/0001_initial.py +69 -0
- karrio/server/documents/migrations/0002_alter_documenttemplate_related_objects.py +18 -0
- karrio/server/documents/migrations/0003_rename_related_objects_documenttemplate_related_object.py +18 -0
- karrio/server/documents/migrations/0004_documenttemplate_active.py +18 -0
- karrio/server/documents/migrations/0005_alter_documenttemplate_description_and_more.py +23 -0
- karrio/server/documents/migrations/0006_documenttemplate_metadata.py +25 -0
- karrio/server/documents/migrations/0007_alter_documenttemplate_related_object.py +18 -0
- karrio/server/documents/migrations/0008_documenttemplate_options.py +26 -0
- karrio/server/documents/migrations/__init__.py +0 -0
- karrio/server/documents/models.py +61 -0
- karrio/server/documents/serializers/__init__.py +4 -0
- karrio/server/documents/serializers/base.py +89 -0
- karrio/server/documents/serializers/documents.py +9 -0
- karrio/server/documents/signals.py +29 -0
- karrio/server/documents/tests/__init__.py +6 -0
- karrio/server/documents/tests/test_generator.py +474 -0
- karrio/server/documents/tests/test_templates.py +318 -0
- karrio/server/documents/tests.py +182 -0
- karrio/server/documents/urls.py +11 -0
- karrio/server/documents/utils.py +2486 -0
- karrio/server/documents/views/__init__.py +0 -0
- karrio/server/documents/views/printers.py +223 -0
- karrio/server/documents/views/templates.py +255 -0
- karrio/server/graph/schemas/__init__.py +1 -0
- karrio/server/graph/schemas/documents/__init__.py +46 -0
- karrio/server/graph/schemas/documents/inputs.py +41 -0
- karrio/server/graph/schemas/documents/mutations.py +51 -0
- karrio/server/graph/schemas/documents/types.py +45 -0
- karrio_server_documents-2025.5.2.dist-info/METADATA +19 -0
- karrio_server_documents-2025.5.2.dist-info/RECORD +37 -0
- karrio_server_documents-2025.5.2.dist-info/WHEEL +5 -0
- 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,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,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"""
|