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,318 @@
1
+ import json
2
+ from unittest.mock import ANY
3
+ from django.urls import reverse
4
+ from rest_framework import status
5
+ from karrio.server.core.tests import APITestCase
6
+ from karrio.server.graph.tests.base import GraphTestCase
7
+ from karrio.server.documents.models import DocumentTemplate
8
+
9
+
10
+ class TestDocumentTemplatesREST(APITestCase):
11
+ def test_create_document_template(self):
12
+ url = reverse("karrio.server.documents:document-template-list")
13
+ data = DOCUMENT_TEMPLATE_DATA
14
+
15
+ response = self.client.post(url, data)
16
+ response_data = json.loads(response.content)
17
+
18
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
19
+
20
+ # Check individual fields instead of strict dictionary comparison
21
+ self.assertEqual(response_data["name"], "Test Invoice Template")
22
+ self.assertEqual(response_data["slug"], "test_invoice")
23
+ self.assertEqual(response_data["description"], "A test invoice template")
24
+ self.assertEqual(response_data["object_type"], "document-template")
25
+ self.assertEqual(response_data["related_object"], "shipment")
26
+ self.assertEqual(response_data["active"], True)
27
+ self.assertEqual(response_data["metadata"], {"doc_type": "invoice", "version": "1.0"})
28
+ self.assertEqual(response_data["options"], {"page_size": "A4", "orientation": "portrait"})
29
+
30
+ # Check that ID field exists
31
+ self.assertIn("id", response_data)
32
+
33
+ def test_list_document_templates(self):
34
+ # Create a template first
35
+ DocumentTemplate.objects.create(
36
+ **{
37
+ "name": "Test Template",
38
+ "slug": "test_template",
39
+ "template": SAMPLE_HTML_TEMPLATE,
40
+ "description": "A test template",
41
+ "related_object": "shipment",
42
+ "created_by": self.user,
43
+ }
44
+ )
45
+
46
+ url = reverse("karrio.server.documents:document-template-list")
47
+ response = self.client.get(url)
48
+ response_data = json.loads(response.content)
49
+
50
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
51
+ self.assertIn("results", response_data)
52
+ self.assertEqual(len(response_data["results"]), 1)
53
+ self.assertEqual(response_data["results"][0]["name"], "Test Template")
54
+
55
+
56
+ class TestDocumentTemplateDetailsREST(APITestCase):
57
+ def setUp(self) -> None:
58
+ super().setUp()
59
+ self.template: DocumentTemplate = DocumentTemplate.objects.create(
60
+ **{
61
+ "name": "Test Template",
62
+ "slug": "test_template",
63
+ "template": SAMPLE_HTML_TEMPLATE,
64
+ "description": "A test template",
65
+ "related_object": "shipment",
66
+ "active": True,
67
+ "metadata": {"doc_type": "invoice"},
68
+ "options": {"page_size": "A4"},
69
+ "created_by": self.user,
70
+ }
71
+ )
72
+
73
+ def test_retrieve_document_template(self):
74
+ url = reverse(
75
+ "karrio.server.documents:document-template-details",
76
+ kwargs=dict(pk=self.template.pk),
77
+ )
78
+
79
+ response = self.client.get(url)
80
+ response_data = json.loads(response.content)
81
+
82
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
83
+
84
+ # Check individual fields instead of strict dictionary comparison
85
+ self.assertEqual(response_data["name"], "Test Template")
86
+ self.assertEqual(response_data["slug"], "test_template")
87
+ self.assertEqual(response_data["description"], "A test template")
88
+ self.assertEqual(response_data["object_type"], "document-template")
89
+ self.assertEqual(response_data["related_object"], "shipment")
90
+ self.assertEqual(response_data["active"], True)
91
+ self.assertEqual(response_data["metadata"], {"doc_type": "invoice"})
92
+ self.assertEqual(response_data["options"], {"page_size": "A4"})
93
+ self.assertEqual(response_data["template"], SAMPLE_HTML_TEMPLATE)
94
+
95
+ # Check that ID field exists
96
+ self.assertIn("id", response_data)
97
+
98
+ def test_update_document_template(self):
99
+ url = reverse(
100
+ "karrio.server.documents:document-template-details",
101
+ kwargs=dict(pk=self.template.pk),
102
+ )
103
+ data = DOCUMENT_TEMPLATE_UPDATE_DATA
104
+
105
+ response = self.client.patch(url, data)
106
+ response_data = json.loads(response.content)
107
+
108
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
109
+ self.assertDictEqual(response_data, DOCUMENT_TEMPLATE_UPDATE_RESPONSE)
110
+
111
+ def test_delete_document_template(self):
112
+ url = reverse(
113
+ "karrio.server.documents:document-template-details",
114
+ kwargs=dict(pk=self.template.pk),
115
+ )
116
+
117
+ response = self.client.delete(url)
118
+ response_data = json.loads(response.content)
119
+
120
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
121
+ # Verify template data is returned (soft delete behavior)
122
+ self.assertEqual(response_data.get("name"), "Test Template")
123
+ self.assertEqual(response_data.get("slug"), "test_template")
124
+ # The template should still be active (or check if it's marked as inactive)
125
+ self.assertIsNotNone(response_data.get("object_type"))
126
+
127
+
128
+ class TestDocumentTemplatesGraphQL(GraphTestCase):
129
+ def test_query_document_templates(self):
130
+ # Create a template first
131
+ DocumentTemplate.objects.create(
132
+ **{
133
+ "name": "GraphQL Test Template",
134
+ "slug": "graphql_test",
135
+ "template": SAMPLE_HTML_TEMPLATE,
136
+ "description": "A GraphQL test template",
137
+ "related_object": "order",
138
+ "created_by": self.user,
139
+ }
140
+ )
141
+
142
+ query = """
143
+ query {
144
+ document_templates {
145
+ edges {
146
+ node {
147
+ id
148
+ name
149
+ slug
150
+ description
151
+ related_object
152
+ active
153
+ object_type
154
+ }
155
+ }
156
+ }
157
+ }
158
+ """
159
+
160
+ result = self.query(query)
161
+ self.assertResponseNoErrors(result)
162
+
163
+ templates = result.data["data"]["document_templates"]["edges"]
164
+ self.assertEqual(len(templates), 1)
165
+ self.assertEqual(templates[0]["node"]["name"], "GraphQL Test Template")
166
+ self.assertEqual(templates[0]["node"]["slug"], "graphql_test")
167
+
168
+ def test_create_document_template_mutation(self):
169
+ mutation = """
170
+ mutation CreateDocumentTemplate($input: CreateDocumentTemplateMutationInput!) {
171
+ create_document_template(input: $input) {
172
+ template {
173
+ id
174
+ name
175
+ slug
176
+ description
177
+ related_object
178
+ active
179
+ }
180
+ errors {
181
+ field
182
+ messages
183
+ }
184
+ }
185
+ }
186
+ """
187
+
188
+ variables = {
189
+ "input": {
190
+ "name": "GraphQL Created Template",
191
+ "slug": "graphql_created",
192
+ "template": SAMPLE_HTML_TEMPLATE,
193
+ "description": "Created via GraphQL",
194
+ "related_object": "shipment",
195
+ "active": True,
196
+ }
197
+ }
198
+
199
+ result = self.query(mutation, variables=variables)
200
+ self.assertResponseNoErrors(result)
201
+
202
+ created_template = result.data["data"]["create_document_template"]["template"]
203
+ self.assertEqual(created_template["name"], "GraphQL Created Template")
204
+ self.assertEqual(created_template["slug"], "graphql_created")
205
+ self.assertTrue(created_template["active"])
206
+
207
+ def test_update_document_template_mutation(self):
208
+ # Create a template first
209
+ template = DocumentTemplate.objects.create(
210
+ **{
211
+ "name": "Original Template",
212
+ "slug": "original",
213
+ "template": SAMPLE_HTML_TEMPLATE,
214
+ "description": "Original description",
215
+ "related_object": "shipment",
216
+ "created_by": self.user,
217
+ }
218
+ )
219
+
220
+ mutation = """
221
+ mutation UpdateDocumentTemplate($input: UpdateDocumentTemplateMutationInput!) {
222
+ update_document_template(input: $input) {
223
+ template {
224
+ id
225
+ name
226
+ description
227
+ active
228
+ }
229
+ errors {
230
+ field
231
+ messages
232
+ }
233
+ }
234
+ }
235
+ """
236
+
237
+ variables = {
238
+ "input": {
239
+ "id": template.id,
240
+ "name": "Updated Template",
241
+ "description": "Updated description",
242
+ "active": False,
243
+ }
244
+ }
245
+
246
+ result = self.query(mutation, variables=variables)
247
+ self.assertResponseNoErrors(result)
248
+
249
+ updated_template = result.data["data"]["update_document_template"]["template"]
250
+ self.assertEqual(updated_template["name"], "Updated Template")
251
+ self.assertEqual(updated_template["description"], "Updated description")
252
+ self.assertFalse(updated_template["active"])
253
+
254
+
255
+ # Test Data and Fixtures
256
+ SAMPLE_HTML_TEMPLATE = """
257
+ <title>{{ title | default('Test Document') }}</title>
258
+ """
259
+
260
+ DOCUMENT_TEMPLATE_DATA = {
261
+ "name": "Test Invoice Template",
262
+ "slug": "test_invoice",
263
+ "template": SAMPLE_HTML_TEMPLATE,
264
+ "description": "A test invoice template",
265
+ "related_object": "shipment",
266
+ "active": True,
267
+ "metadata": {"doc_type": "invoice", "version": "1.0"},
268
+ "options": {"page_size": "A4", "orientation": "portrait"},
269
+ }
270
+
271
+ DOCUMENT_TEMPLATE_RESPONSE = {
272
+ "id": ANY,
273
+ "object_type": "document-template",
274
+ "name": "Test Invoice Template",
275
+ "slug": "test_invoice",
276
+ "template": SAMPLE_HTML_TEMPLATE,
277
+ "description": "A test invoice template",
278
+ "related_object": "shipment",
279
+ "active": True,
280
+ "metadata": {"doc_type": "invoice", "version": "1.0"},
281
+ "options": {"page_size": "A4", "orientation": "portrait"},
282
+ "preview_url": ANY,
283
+ }
284
+
285
+ DOCUMENT_TEMPLATE_DETAIL_RESPONSE = {
286
+ "active": True,
287
+ "description": "A test template",
288
+ "id": ANY,
289
+ "metadata": {"doc_type": "invoice"},
290
+ "name": "Test Template",
291
+ "object_type": "document-template",
292
+ "options": {"page_size": "A4"},
293
+ "related_object": "shipment",
294
+ "slug": "test_template",
295
+ "template": SAMPLE_HTML_TEMPLATE,
296
+ "preview_url": ANY,
297
+ }
298
+
299
+ DOCUMENT_TEMPLATE_UPDATE_DATA = {
300
+ "name": "Updated Test Template",
301
+ "description": "An updated test template",
302
+ "active": False,
303
+ "metadata": {"doc_type": "commercial_invoice", "version": "2.0"},
304
+ }
305
+
306
+ DOCUMENT_TEMPLATE_UPDATE_RESPONSE = {
307
+ "id": ANY,
308
+ "object_type": "document-template",
309
+ "name": "Updated Test Template",
310
+ "slug": "test_template",
311
+ "template": SAMPLE_HTML_TEMPLATE,
312
+ "description": "An updated test template",
313
+ "related_object": "shipment",
314
+ "active": False,
315
+ "metadata": {"doc_type": "commercial_invoice", "version": "2.0"},
316
+ "options": {"page_size": "A4"},
317
+ "preview_url": ANY,
318
+ }
@@ -0,0 +1,182 @@
1
+ import json
2
+ from django.test import TestCase
3
+ from rest_framework.test import APIClient
4
+ from rest_framework import status
5
+ from unittest.mock import patch
6
+
7
+ from karrio.server.documents.generator import Documents, TemplateRenderingError
8
+
9
+
10
+ class DocumentGenerationErrorHandlingTest(TestCase):
11
+ def setUp(self):
12
+ self.client = APIClient()
13
+ # Mock authentication for testing
14
+ with patch('karrio.server.core.views.api.APIView.permission_classes', []):
15
+ pass
16
+
17
+ def test_template_with_undefined_variable_returns_error(self):
18
+ """Test that template with undefined variables returns proper error message"""
19
+ template = "<div>{{ shipment.shipper.address_line1 }}</div>"
20
+ data = {
21
+ "template": template,
22
+ "doc_format": "html",
23
+ "doc_name": "Test Document",
24
+ "data": {
25
+ "object": {"id": "test123"},
26
+ # Note: no shipment data provided, so shipment.shipper.address_line1 will be undefined
27
+ }
28
+ }
29
+
30
+ # Mock the permission check
31
+ with patch('karrio.server.core.views.api.APIView.check_permissions'):
32
+ response = self.client.post(
33
+ '/v1/documents/generate',
34
+ data=json.dumps(data),
35
+ content_type='application/json'
36
+ )
37
+
38
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
39
+ self.assertIn('errors', response.json())
40
+ self.assertIn('Template variable error', response.json()['errors'][0]['message'])
41
+ self.assertIn('shipment', response.json()['errors'][0]['message'])
42
+
43
+ def test_template_syntax_error_returns_error(self):
44
+ """Test that template with syntax errors returns proper error message"""
45
+ template = "<div>{{ unclosed_tag</div>" # Missing closing }}
46
+ data = {
47
+ "template": template,
48
+ "doc_format": "html",
49
+ "doc_name": "Test Document",
50
+ "data": {"test": "data"}
51
+ }
52
+
53
+ with patch('karrio.server.core.views.api.APIView.check_permissions'):
54
+ response = self.client.post(
55
+ '/v1/documents/generate',
56
+ data=json.dumps(data),
57
+ content_type='application/json'
58
+ )
59
+
60
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
61
+ self.assertIn('errors', response.json())
62
+ self.assertIn('Template syntax error', response.json()['errors'][0]['message'])
63
+
64
+ def test_missing_template_returns_error(self):
65
+ """Test that missing template returns proper error message"""
66
+ data = {
67
+ "doc_format": "html",
68
+ "doc_name": "Test Document",
69
+ "data": {"test": "data"}
70
+ # Note: no template or template_id provided
71
+ }
72
+
73
+ with patch('karrio.server.core.views.api.APIView.check_permissions'):
74
+ response = self.client.post(
75
+ '/v1/documents/generate',
76
+ data=json.dumps(data),
77
+ content_type='application/json'
78
+ )
79
+
80
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
81
+ self.assertIn('errors', response.json())
82
+ self.assertEqual(
83
+ response.json()['errors'][0]['message'],
84
+ 'template or template_id is required'
85
+ )
86
+
87
+ def test_valid_template_with_data_succeeds(self):
88
+ """Test that valid template with proper data succeeds"""
89
+ template = "<div>Hello {{ name }}!</div>"
90
+ data = {
91
+ "template": template,
92
+ "doc_format": "html",
93
+ "doc_name": "Test Document",
94
+ "data": {
95
+ "name": "World",
96
+ "shipment": {
97
+ "shipper": {"address_line1": "123 Test St"}
98
+ }
99
+ }
100
+ }
101
+
102
+ # Mock PDF generation to avoid WeasyPrint dependency in tests
103
+ with patch('karrio.server.core.views.api.APIView.check_permissions'), \
104
+ patch('karrio.server.documents.generator.weasyprint.HTML') as mock_html:
105
+
106
+ # Mock the PDF generation
107
+ mock_buffer = b"fake pdf content"
108
+ mock_html.return_value.write_pdf.return_value = None
109
+
110
+ # Patch the buffer creation
111
+ with patch('karrio.server.documents.generator.io.BytesIO') as mock_bytesio:
112
+ mock_bytesio.return_value.getvalue.return_value = mock_buffer
113
+
114
+ response = self.client.post(
115
+ '/v1/documents/generate',
116
+ data=json.dumps(data),
117
+ content_type='application/json'
118
+ )
119
+
120
+ # Should succeed (201) or fail gracefully with a proper error message
121
+ self.assertIn(response.status_code, [status.HTTP_201_CREATED, status.HTTP_400_BAD_REQUEST])
122
+
123
+ if response.status_code == status.HTTP_400_BAD_REQUEST:
124
+ # If it fails, it should be with a proper error message, not a 500
125
+ self.assertIn('errors', response.json())
126
+ error_message = response.json()['errors'][0]['message']
127
+ # Should not be a generic "internal server error"
128
+ self.assertNotIn('Internal server error', error_message)
129
+
130
+ def test_template_rendering_error_direct(self):
131
+ """Test TemplateRenderingError class directly"""
132
+ try:
133
+ # Test with a template that has undefined variables
134
+ Documents.generate(
135
+ template="<div>{{ undefined_var.nested.property }}</div>",
136
+ data={"some_data": "test"}
137
+ )
138
+ self.fail("Should have raised TemplateRenderingError")
139
+ except TemplateRenderingError as e:
140
+ self.assertIn("Template variable error", e.message)
141
+ self.assertIn("undefined_var", e.message)
142
+ self.assertIn("Available variables", e.message)
143
+ except Exception as e:
144
+ self.fail(f"Should have raised TemplateRenderingError, got {type(e).__name__}: {e}")
145
+
146
+
147
+ class DocumentGenerationContextTest(TestCase):
148
+ """Test that data contexts are properly handled"""
149
+
150
+ def test_shipment_context_properly_passed(self):
151
+ """Test that shipment data is properly passed to template context"""
152
+ template = "<div>{{ shipment.shipper.address_line1 }}</div>"
153
+ data = {
154
+ "shipment": {
155
+ "shipper": {"address_line1": "123 Test Street"}
156
+ }
157
+ }
158
+
159
+ try:
160
+ # This should work without errors
161
+ result = Documents.generate(template, data=data)
162
+ # If we get here, the context was properly passed
163
+ self.assertIsNotNone(result)
164
+ except TemplateRenderingError as e:
165
+ # If it fails, it should be due to PDF generation, not template rendering
166
+ if "PDF generation error" not in e.message:
167
+ self.fail(f"Template rendering failed: {e.message}")
168
+
169
+ def test_generic_context_fallback(self):
170
+ """Test that generic context fallback works for arbitrary data"""
171
+ template = "<div>{{ custom_field }}</div>"
172
+ data = {
173
+ "custom_field": "test value"
174
+ }
175
+
176
+ try:
177
+ result = Documents.generate(template, data=data)
178
+ self.assertIsNotNone(result)
179
+ except TemplateRenderingError as e:
180
+ # Should work for simple templates
181
+ if "PDF generation error" not in e.message:
182
+ self.fail(f"Template rendering failed: {e.message}")
@@ -0,0 +1,11 @@
1
+ """
2
+ karrio server documents module urls
3
+ """
4
+
5
+ from django.urls import include, path
6
+
7
+ app_name = "karrio.server.documents"
8
+ urlpatterns = [
9
+ path("", include("karrio.server.documents.views.printers")),
10
+ path("v1/", include("karrio.server.documents.views.templates")),
11
+ ]