karrio-server-documents 2025.5rc14__py3-none-any.whl → 2025.5rc15__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.

@@ -37,6 +37,15 @@ UNITS = {
37
37
  }
38
38
 
39
39
 
40
+ class TemplateRenderingError(Exception):
41
+ """Custom exception for template rendering errors"""
42
+ def __init__(self, message, line_number=None, template_error=None):
43
+ self.message = message
44
+ self.line_number = line_number
45
+ self.template_error = template_error
46
+ super().__init__(self.message)
47
+
48
+
40
49
  class Documents:
41
50
  @staticmethod
42
51
  def generate(
@@ -47,6 +56,8 @@ class Documents:
47
56
  ) -> io.BytesIO:
48
57
  options = kwargs.get("options") or {}
49
58
  metadata = kwargs.get("metadata") or {}
59
+
60
+ # Build contexts based on related_object and provided data
50
61
  shipment_contexts = data.get("shipments_context") or lib.identity(
51
62
  get_shipments_context(data["shipments"])
52
63
  if "shipments" in data and related_object == "shipment"
@@ -57,63 +68,120 @@ class Documents:
57
68
  if "orders" in data and related_object == "order"
58
69
  else []
59
70
  )
71
+
72
+ # For generic contexts, include the full data structure
60
73
  generic_contexts = data.get("generic_context") or lib.identity(
61
- [{"data": data}] if related_object is None else []
74
+ [data] if related_object is None else []
62
75
  )
76
+
77
+ # If no specific contexts are provided but we have a related_object, create a context with the data
78
+ if not shipment_contexts and not order_contexts and not generic_contexts:
79
+ if related_object == "shipment" and data:
80
+ # For shipment templates, ensure we have the expected structure
81
+ generic_contexts = [data]
82
+ elif related_object == "order" and data:
83
+ # For order templates, ensure we have the expected structure
84
+ generic_contexts = [data]
85
+ else:
86
+ # Default fallback
87
+ generic_contexts = [data]
88
+
63
89
  filename = lib.identity(
64
90
  dict(filename=kwargs.get("doc_name")) if kwargs.get("doc_name") else {}
65
91
  )
66
92
 
67
- prefetch = lambda ctx: {
68
- k: v
69
- for o in lib.run_concurently(
70
- lambda _: {
71
- _[0]: str(
72
- lib.failsafe(
73
- lambda: _[1].render(
74
- **ctx,
75
- metadata=metadata,
76
- units=UNITS,
77
- utils=utils,
78
- lib=lib,
79
- )
80
- )
81
- or ""
93
+ def safe_render_prefetch(ctx):
94
+ """Safely render prefetch templates with error handling"""
95
+ result = {}
96
+ for key, value in options.get("prefetch", {}).items():
97
+ try:
98
+ template_obj = jinja2.Template(value)
99
+ rendered = template_obj.render(
100
+ **ctx,
101
+ metadata=metadata,
102
+ units=UNITS,
103
+ utils=utils,
104
+ lib=lib,
82
105
  )
83
- },
84
- [
85
- (key, jinja2.Template(value))
86
- for key, value in options.get("prefetch", {}).items()
87
- ],
106
+ result[key] = str(rendered or "")
107
+ except jinja2.TemplateError as e:
108
+ # Log the error but continue with empty string
109
+ result[key] = ""
110
+ return result
111
+
112
+ try:
113
+ jinja_template = jinja2.Template(template)
114
+ except jinja2.TemplateSyntaxError as e:
115
+ raise TemplateRenderingError(
116
+ f"Template syntax error: {e.message}",
117
+ line_number=e.lineno,
118
+ template_error=e
88
119
  )
89
- for k, v in o.items()
90
- }
91
120
 
92
- jinja_template = jinja2.Template(template)
93
121
  all_contexts = shipment_contexts + order_contexts + generic_contexts
94
- rendered_pages = lib.run_asynchronously(
95
- lambda ctx: jinja_template.render(
96
- **ctx,
97
- metadata=metadata,
98
- units=UNITS,
99
- utils=utils,
100
- lib=lib,
101
- prefetch=prefetch(ctx),
102
- ),
103
- all_contexts,
104
- )
105
- content = PAGE_SEPARATOR.join(rendered_pages)
106
122
 
107
- buffer = io.BytesIO()
108
- html = weasyprint.HTML(string=content, encoding="utf-8")
109
- html.write_pdf(
110
- buffer,
111
- stylesheets=STYLESHEETS,
112
- font_config=FONT_CONFIG,
113
- optimize_size=("fonts", "images"),
114
- )
123
+ # If no contexts are available, create a default one
124
+ if not all_contexts:
125
+ all_contexts = [data or {}]
115
126
 
116
- return buffer
127
+ rendered_pages = []
128
+ for ctx in all_contexts:
129
+ try:
130
+ # Add prefetch data safely
131
+ ctx_with_prefetch = {
132
+ **ctx,
133
+ "prefetch": safe_render_prefetch(ctx)
134
+ }
135
+
136
+ rendered_page = jinja_template.render(
137
+ **ctx_with_prefetch,
138
+ metadata=metadata,
139
+ units=UNITS,
140
+ utils=utils,
141
+ lib=lib,
142
+ )
143
+ rendered_pages.append(rendered_page)
144
+ except jinja2.UndefinedError as e:
145
+ raise TemplateRenderingError(
146
+ f"Template variable error: {str(e)}. Available variables: {list(ctx.keys())}",
147
+ template_error=e
148
+ )
149
+ except jinja2.TemplateRuntimeError as e:
150
+ raise TemplateRenderingError(
151
+ f"Template runtime error: {str(e)}",
152
+ line_number=getattr(e, 'lineno', None),
153
+ template_error=e
154
+ )
155
+ except jinja2.TemplateError as e:
156
+ raise TemplateRenderingError(
157
+ f"Template error: {str(e)}",
158
+ line_number=getattr(e, 'lineno', None),
159
+ template_error=e
160
+ )
161
+ except Exception as e:
162
+ raise TemplateRenderingError(
163
+ f"Unexpected error during template rendering: {str(e)}",
164
+ template_error=e
165
+ )
166
+
167
+ content = PAGE_SEPARATOR.join(rendered_pages)
168
+
169
+ # Handle PDF generation errors
170
+ try:
171
+ buffer = io.BytesIO()
172
+ html = weasyprint.HTML(string=content, encoding="utf-8")
173
+ html.write_pdf(
174
+ buffer,
175
+ stylesheets=STYLESHEETS,
176
+ font_config=FONT_CONFIG,
177
+ optimize_size=("fonts", "images"),
178
+ )
179
+ return buffer
180
+ except Exception as e:
181
+ raise TemplateRenderingError(
182
+ f"PDF generation error: {str(e)}",
183
+ template_error=e
184
+ )
117
185
 
118
186
  @staticmethod
119
187
  def generate_template(
@@ -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}")
@@ -167,34 +167,74 @@ class DocumentGenerator(api.APIView):
167
167
  This API is designed to be used to generate GS1 labels,
168
168
  invoices and any document that requires external data.
169
169
  """
170
- data = serializers.DocumentData.map(data=request.data).data
170
+ try:
171
+ data = serializers.DocumentData.map(data=request.data).data
171
172
 
172
- if data.get("template") is None and data.get("template_id") is None:
173
- raise serializers.ValidationError("template or template_id is required")
173
+ if data.get("template") is None and data.get("template_id") is None:
174
+ return Response(
175
+ {"errors": [{"message": "template or template_id is required"}]},
176
+ status=status.HTTP_400_BAD_REQUEST,
177
+ )
174
178
 
175
- document_template = lib.identity(
176
- None
177
- if data.get("template_id") is None
178
- else models.DocumentTemplate.objects.get(pk=data.get("template_id"))
179
- )
179
+ document_template = lib.identity(
180
+ None
181
+ if data.get("template_id") is None
182
+ else models.DocumentTemplate.objects.get(pk=data.get("template_id"))
183
+ )
180
184
 
181
- doc_file = generator.Documents.generate(
182
- getattr(document_template, "template", data.get("template")),
183
- related_object=getattr(document_template, "related_object", None),
184
- metadata=getattr(document_template, "metadata", {}),
185
- data=data.get("data"),
186
- options=data.get("options"),
187
- doc_name=data.get("doc_name"),
188
- doc_fomat=data.get("doc_format"),
189
- )
190
- document = serializers.GeneratedDocument.map(
191
- data={
192
- **data,
193
- "doc_file": base64.b64encode(doc_file.getvalue()).decode("utf-8"),
194
- }
195
- )
185
+ try:
186
+ doc_file = generator.Documents.generate(
187
+ getattr(document_template, "template", data.get("template")),
188
+ related_object=getattr(document_template, "related_object", None),
189
+ metadata=getattr(document_template, "metadata", {}),
190
+ data=data.get("data", {}),
191
+ options=data.get("options", {}),
192
+ doc_name=data.get("doc_name"),
193
+ doc_format=data.get("doc_format"),
194
+ )
195
+
196
+ document = serializers.GeneratedDocument.map(
197
+ data={
198
+ **data,
199
+ "doc_file": base64.b64encode(doc_file.getvalue()).decode("utf-8"),
200
+ }
201
+ )
196
202
 
197
- return Response(document.data, status=status.HTTP_201_CREATED)
203
+ return Response(document.data, status=status.HTTP_201_CREATED)
204
+
205
+ except generator.TemplateRenderingError as e:
206
+ error_message = e.message
207
+ if e.line_number:
208
+ error_message += f" (line {e.line_number})"
209
+
210
+ return Response(
211
+ {"errors": [{"message": error_message}]},
212
+ status=status.HTTP_400_BAD_REQUEST,
213
+ )
214
+ except models.DocumentTemplate.DoesNotExist:
215
+ return Response(
216
+ {"errors": [{"message": f"Document template with id '{data.get('template_id')}' not found"}]},
217
+ status=status.HTTP_404_NOT_FOUND,
218
+ )
219
+ except Exception as e:
220
+ logger.exception(f"Unexpected error during document generation: {e}")
221
+ return Response(
222
+ {"errors": [{"message": f"Document generation failed: {str(e)}"}]},
223
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
224
+ )
225
+
226
+ except serializers.ValidationError as e:
227
+ logger.error(f"Validation error: {e}")
228
+ return Response(
229
+ {"errors": [{"message": "Invalid input data"}]},
230
+ status=status.HTTP_400_BAD_REQUEST,
231
+ )
232
+ except Exception as e:
233
+ logger.exception(f"Unexpected error in document generator: {e}")
234
+ return Response(
235
+ {"errors": [{"message": "Internal server error"}]},
236
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
237
+ )
198
238
 
199
239
 
200
240
  urlpatterns = [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: karrio_server_documents
3
- Version: 2025.5rc14
3
+ Version: 2025.5rc15
4
4
  Summary: Multi-carrier shipping API apps module
5
5
  Author-email: karrio <hello@karrio.io>
6
6
  License-Expression: Apache-2.0
@@ -2,9 +2,10 @@ karrio/server/documents/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG
2
2
  karrio/server/documents/admin.py,sha256=TAkENFDUEnRvOXvKL_6Nz-kmi5sosHvM04W6zCLeIBA,3464
3
3
  karrio/server/documents/apps.py,sha256=cH7FOtpePV56R4mW1KIlTdySGavZYZZ9hEwo3xmwgLo,359
4
4
  karrio/server/documents/filters.py,sha256=cLJNRFAnmzRNErUwnx9xSj413q2da5hrjRN2U3KPbrU,460
5
- karrio/server/documents/generator.py,sha256=OV_lbBPX-pDJhjFKscDbBcF2fb4IE-hgbaq35oXBMLE,7416
5
+ karrio/server/documents/generator.py,sha256=Dh__0UAU0bKgMr28O3FXHKB_yN1a_Bdm-aO_Lz_96xk,10326
6
6
  karrio/server/documents/models.py,sha256=btjONslnF7BYmDR6_EAmI2K8F96xC2w2WV2F8FNE9m4,1724
7
7
  karrio/server/documents/signals.py,sha256=HY4GvQuLEgCgQ2ONNVVKLuZtoOVZvbkVYOpwnHHFyEk,937
8
+ karrio/server/documents/tests.py,sha256=_DhWOXdu2jl1xPbyveqlZiJReSHHt0kMQ8fsvmbRuI8,7437
8
9
  karrio/server/documents/urls.py,sha256=JPGYWhRS_Ts3g4QRPJ6r93JDf_V7ucII_5xSOP93NHg,273
9
10
  karrio/server/documents/utils.py,sha256=ElTfhi4lkU3ZjzYf_eW6FdXhAHmXuKR0u7YpDymJodQ,119469
10
11
  karrio/server/documents/migrations/0001_initial.py,sha256=_4SvF3POad9c77tYuyjgjidatJRvYPlGIakE3ja-bhg,2406
@@ -24,13 +25,13 @@ karrio/server/documents/tests/test_generator.py,sha256=JgTziVt9Sn_VuVU2axvUUOTp0
24
25
  karrio/server/documents/tests/test_templates.py,sha256=fenggMLz5svtSSQc3k1GnFMnnHTmJqN0hpX2uHuaNrY,11268
25
26
  karrio/server/documents/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
27
  karrio/server/documents/views/printers.py,sha256=OsgMLIZ2y64Sr_9Z020n0mWL28TlUjq8iN4wULJuxsM,7343
27
- karrio/server/documents/views/templates.py,sha256=vDxU2wtjXMus6vHO17cZTFW_PTWms9r2Gsj0LuPN_kY,7063
28
+ karrio/server/documents/views/templates.py,sha256=uR2fwdaHSrQTlEe4mYl8EsVIaVhHi-aoxIOko0DpEMU,8881
28
29
  karrio/server/graph/schemas/__init__.py,sha256=iOEMwnlORWezdO8-2vxBIPSR37D7JGjluZ8f55vzxls,81
29
30
  karrio/server/graph/schemas/documents/__init__.py,sha256=GV9yk56ddIYnB9PP_AtXnucs3SIszUJpe2Jv9sHdzlo,1620
30
31
  karrio/server/graph/schemas/documents/inputs.py,sha256=Ri2P_VJjUsFZYy8dIuHnUzHlbWxgSWJhpy9J8TS8Y1Q,1288
31
32
  karrio/server/graph/schemas/documents/mutations.py,sha256=5m-3w7ZVRsl4SiagpN4XN1nN1TW_3eLrqRCjhaFRtG0,1849
32
33
  karrio/server/graph/schemas/documents/types.py,sha256=_UgtdgnGDUeMXW9HGHNVdcP7YeN6hnsnTHYZZgaiH5M,1537
33
- karrio_server_documents-2025.5rc14.dist-info/METADATA,sha256=Doz5VUJ2T7Rkham_lrv9EOCFHRUvqgnxwBOBNRUpsNM,565
34
- karrio_server_documents-2025.5rc14.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
35
- karrio_server_documents-2025.5rc14.dist-info/top_level.txt,sha256=D1D7x8R3cTfjF_15mfiO7wCQ5QMtuM4x8GaPr7z5i78,12
36
- karrio_server_documents-2025.5rc14.dist-info/RECORD,,
34
+ karrio_server_documents-2025.5rc15.dist-info/METADATA,sha256=W3-Uhahv0mPkokGzqDC4fVmwp_JBYxqeb6FN4zkpMzA,565
35
+ karrio_server_documents-2025.5rc15.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
36
+ karrio_server_documents-2025.5rc15.dist-info/top_level.txt,sha256=D1D7x8R3cTfjF_15mfiO7wCQ5QMtuM4x8GaPr7z5i78,12
37
+ karrio_server_documents-2025.5rc15.dist-info/RECORD,,