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
File without changes
@@ -0,0 +1,93 @@
1
+ import django.forms as forms
2
+ from django.contrib import admin
3
+ from django.conf import settings
4
+ from django.utils.translation import gettext_lazy as _
5
+ from django.utils.html import format_html
6
+ from django.urls import reverse
7
+
8
+ import karrio.server.documents.models as models
9
+ import karrio.server.documents.serializers.base as serializers
10
+
11
+
12
+ class DocumentTemplateAdminForm(forms.ModelForm):
13
+ class Meta:
14
+ model = models.DocumentTemplate
15
+ fields = "__all__"
16
+ widgets = {
17
+ "template": forms.Textarea(attrs={"rows": 30, "cols": 100}),
18
+ "related_object": forms.Select(
19
+ choices=[
20
+ (c.name, c.name) for c in list(serializers.TemplateRelatedObject)
21
+ ]
22
+ ),
23
+ }
24
+
25
+
26
+ class DocumentTemplateAdmin(admin.ModelAdmin):
27
+ form = DocumentTemplateAdminForm
28
+ list_display = ("slug", "name", "related_object", "active", "created_at", "preview_link")
29
+ search_fields = ("name", "description", "slug", "related_object")
30
+ list_filter = ("slug", "active")
31
+ readonly_fields = ("created_by", "full_preview_url")
32
+
33
+ def preview_link(self, obj):
34
+ """Return a clickable preview link that opens in a new tab"""
35
+ url = obj.preview_url
36
+ return format_html(
37
+ '<a href="{}" target="_blank" rel="noopener noreferrer">Preview</a>',
38
+ url
39
+ )
40
+
41
+ preview_link.short_description = "Preview"
42
+
43
+ def full_preview_url(self, obj):
44
+ """Return the full absolute URL for the preview"""
45
+ if obj.pk:
46
+ request = self.request if hasattr(self, 'request') else None
47
+ if request:
48
+ relative_url = obj.preview_url
49
+ absolute_url = request.build_absolute_uri(relative_url)
50
+ return format_html(
51
+ '<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
52
+ absolute_url, absolute_url
53
+ )
54
+ else:
55
+ # Fallback if request is not available
56
+ relative_url = obj.preview_url
57
+ return f"(Relative URL: {relative_url})"
58
+ return "Save the template first to generate preview URL"
59
+
60
+ full_preview_url.short_description = "Full Preview URL"
61
+
62
+ def get_queryset(self, request):
63
+ if settings.MULTI_ORGANIZATIONS:
64
+ return models.DocumentTemplate.objects.all().filter(
65
+ link__org__users__id=request.user.id
66
+ )
67
+
68
+ return super().get_queryset(request)
69
+
70
+ def save_model(self, request, obj, form, change):
71
+ # Store request for use in full_preview_url method
72
+ self.request = request
73
+ obj.created_by = request.user
74
+ super().save_model(request, obj, form, change)
75
+
76
+ if settings.MULTI_ORGANIZATIONS:
77
+ import karrio.server.serializers as serializers
78
+
79
+ serializers.link_org(obj, request)
80
+
81
+ def change_view(self, request, object_id, form_url='', extra_context=None):
82
+ # Store request for use in full_preview_url method
83
+ self.request = request
84
+ return super().change_view(request, object_id, form_url, extra_context)
85
+
86
+ def add_view(self, request, form_url='', extra_context=None):
87
+ # Store request for use in full_preview_url method
88
+ self.request = request
89
+ return super().add_view(request, form_url, extra_context)
90
+
91
+
92
+ # Register your models here.
93
+ admin.site.register(models.DocumentTemplate, DocumentTemplateAdmin)
@@ -0,0 +1,13 @@
1
+ from django.apps import AppConfig
2
+ from django.utils.translation import gettext_lazy as _
3
+
4
+
5
+ class DocumentsConfig(AppConfig):
6
+ name = "karrio.server.documents"
7
+ verbose_name = _("Documents")
8
+ default_auto_field = "django.db.models.BigAutoField"
9
+
10
+ def ready(self):
11
+ from karrio.server.documents import signals
12
+
13
+ signals.register_all()
@@ -0,0 +1,14 @@
1
+ import karrio.server.filters as filters
2
+ import karrio.server.documents.models as models
3
+
4
+
5
+ class DocumentTemplateFilter(filters.FilterSet):
6
+ name = filters.CharFilter(field_name="name", lookup_expr="icontains")
7
+ related_object = filters.CharFilter(
8
+ field_name="related_object", lookup_expr="icontains"
9
+ )
10
+ active = filters.BooleanFilter(field_name="active")
11
+
12
+ class Meta:
13
+ model = models.DocumentTemplate
14
+ fields: list = []
@@ -0,0 +1,298 @@
1
+ import io
2
+ import typing
3
+ import base64
4
+ import jinja2
5
+ import weasyprint
6
+ from django.db.models import Sum
7
+ import weasyprint.text.fonts as fonts
8
+
9
+ import karrio.lib as lib
10
+ import karrio.server.documents.utils as utils
11
+ import karrio.server.core.dataunits as dataunits
12
+ import karrio.server.orders.models as order_models
13
+ import karrio.server.manager.models as manager_models
14
+ import karrio.server.documents.models as document_models
15
+ import karrio.server.orders.serializers as orders_serializers
16
+ import karrio.server.manager.serializers as manager_serializers
17
+
18
+ FONT_CONFIG = fonts.FontConfiguration()
19
+ PAGE_SEPARATOR = '<p style="page-break-before: always"></p>'
20
+ STYLESHEETS = [
21
+ weasyprint.CSS(url="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css"),
22
+ weasyprint.CSS(
23
+ string="""
24
+ @page { margin: 1cm }
25
+ @font-face {
26
+ font-family: 'system';
27
+ src: local('Arial');
28
+ }
29
+ body { font-family: 'system', sans-serif; }
30
+ """
31
+ ),
32
+ ]
33
+ UNITS = {
34
+ "PAGE_SEPARATOR": PAGE_SEPARATOR,
35
+ "CountryISO": lib.units.CountryISO.as_dict(),
36
+ **dataunits.REFERENCE_MODELS,
37
+ }
38
+
39
+
40
+ class TemplateRenderingError(Exception):
41
+ """Custom exception for template rendering errors"""
42
+
43
+ def __init__(self, message, line_number=None, template_error=None):
44
+ self.message = message
45
+ self.line_number = line_number
46
+ self.template_error = template_error
47
+ super().__init__(self.message)
48
+
49
+
50
+ class Documents:
51
+ @staticmethod
52
+ def generate(
53
+ template: str,
54
+ data: dict = {},
55
+ related_object: str = None,
56
+ **kwargs,
57
+ ) -> io.BytesIO:
58
+ options = kwargs.get("options") or {}
59
+ metadata = kwargs.get("metadata") or {}
60
+
61
+ # Build contexts based on related_object and provided data
62
+ shipment_contexts = data.get("shipments_context") or lib.identity(
63
+ get_shipments_context(data["shipments"])
64
+ if "shipments" in data and related_object == "shipment"
65
+ else []
66
+ )
67
+ order_contexts = data.get("orders_context") or lib.identity(
68
+ get_orders_context(data["orders"])
69
+ if "orders" in data and related_object == "order"
70
+ else []
71
+ )
72
+
73
+ # For generic contexts, include the full data structure
74
+ generic_contexts = data.get("generic_context") or lib.identity(
75
+ [{"data": data}] if related_object is None else []
76
+ )
77
+
78
+ # If no specific contexts are provided but we have a related_object, create a context with the data
79
+ if not shipment_contexts and not order_contexts and not generic_contexts:
80
+ # For fallback cases, always wrap data to maintain legacy behavior
81
+ generic_contexts = [{"data": data}]
82
+
83
+ filename = lib.identity(
84
+ dict(filename=kwargs.get("doc_name")) if kwargs.get("doc_name") else {}
85
+ )
86
+
87
+ def safe_render_prefetch(ctx):
88
+ """Safely render prefetch templates with error handling"""
89
+ result = {}
90
+ for key, value in options.get("prefetch", {}).items():
91
+ try:
92
+ template_obj = jinja2.Template(value)
93
+ rendered = template_obj.render(
94
+ **ctx,
95
+ metadata=metadata,
96
+ units=UNITS,
97
+ utils=utils,
98
+ lib=lib,
99
+ )
100
+ result[key] = str(rendered or "")
101
+ except jinja2.TemplateError as e:
102
+ # Log the error but continue with empty string
103
+ result[key] = ""
104
+ return result
105
+
106
+ try:
107
+ jinja_template = jinja2.Template(template)
108
+ except jinja2.TemplateSyntaxError as e:
109
+ raise TemplateRenderingError(
110
+ f"Template syntax error: {e.message}",
111
+ line_number=e.lineno,
112
+ template_error=e,
113
+ )
114
+
115
+ all_contexts = shipment_contexts + order_contexts + generic_contexts
116
+
117
+ # If no contexts are available, create a default one
118
+ if not all_contexts:
119
+ all_contexts = [{"data": data} if data else {}]
120
+
121
+ rendered_pages = []
122
+ for ctx in all_contexts:
123
+ try:
124
+ # Add prefetch data safely
125
+ ctx_with_prefetch = {**ctx, "prefetch": safe_render_prefetch(ctx)}
126
+
127
+ rendered_page = jinja_template.render(
128
+ **ctx_with_prefetch,
129
+ metadata=metadata,
130
+ units=UNITS,
131
+ utils=utils,
132
+ lib=lib,
133
+ )
134
+ rendered_pages.append(rendered_page)
135
+ except jinja2.UndefinedError as e:
136
+ raise TemplateRenderingError(
137
+ f"Template variable error: {str(e)}. Available variables: {list(ctx.keys())}",
138
+ template_error=e,
139
+ )
140
+ except jinja2.TemplateRuntimeError as e:
141
+ raise TemplateRenderingError(
142
+ f"Template runtime error: {str(e)}",
143
+ line_number=getattr(e, "lineno", None),
144
+ template_error=e,
145
+ )
146
+ except jinja2.TemplateError as e:
147
+ raise TemplateRenderingError(
148
+ f"Template error: {str(e)}",
149
+ line_number=getattr(e, "lineno", None),
150
+ template_error=e,
151
+ )
152
+ except Exception as e:
153
+ raise TemplateRenderingError(
154
+ f"Unexpected error during template rendering: {str(e)}",
155
+ template_error=e,
156
+ )
157
+
158
+ content = PAGE_SEPARATOR.join(rendered_pages)
159
+
160
+ # Handle PDF generation errors
161
+ try:
162
+ buffer = io.BytesIO()
163
+ html = weasyprint.HTML(string=content, encoding="utf-8")
164
+ html.write_pdf(
165
+ buffer,
166
+ stylesheets=STYLESHEETS,
167
+ font_config=FONT_CONFIG,
168
+ optimize_size=("fonts", "images"),
169
+ )
170
+ return buffer
171
+ except Exception as e:
172
+ raise TemplateRenderingError(
173
+ f"PDF generation error: {str(e)}", template_error=e
174
+ )
175
+
176
+ @staticmethod
177
+ def generate_template(
178
+ document: document_models.DocumentTemplate,
179
+ data: dict,
180
+ **kwargs,
181
+ ) -> io.BytesIO:
182
+ return Documents.generate(
183
+ template=document.template,
184
+ data=data,
185
+ options=document.options,
186
+ metadata=document.metadata,
187
+ related_object=document.related_object,
188
+ **kwargs,
189
+ )
190
+
191
+ @staticmethod
192
+ def generate_shipment_document(slug: str, shipment, **kwargs) -> dict:
193
+ template = document_models.DocumentTemplate.objects.get(slug=slug)
194
+ carrier = kwargs.get("carrier") or getattr(
195
+ shipment, "selected_rate_carrier", None
196
+ )
197
+ params = dict(
198
+ shipments_context=[
199
+ dict(
200
+ shipment=manager_serializers.Shipment(shipment).data,
201
+ line_items=get_shipment_item_contexts(shipment),
202
+ carrier=get_carrier_context(carrier),
203
+ orders=orders_serializers.Order(
204
+ get_shipment_order_contexts(shipment),
205
+ many=True,
206
+ ).data,
207
+ )
208
+ ]
209
+ )
210
+ document = Documents.generate_template(template, params).getvalue()
211
+
212
+ return dict(
213
+ doc_format="PDF",
214
+ doc_name=f"{template.name}.pdf",
215
+ doc_type=(template.metadata or {}).get("doc_type") or "commercial_invoice",
216
+ doc_file=base64.b64encode(document).decode("utf-8"),
217
+ )
218
+
219
+
220
+ # -----------------------------------------------------------
221
+ # contexts data parsers
222
+ # -----------------------------------------------------------
223
+ # region
224
+
225
+
226
+ def get_shipments_context(shipment_ids: str) -> typing.List[dict]:
227
+ if shipment_ids == "sample":
228
+ return [utils.SHIPMENT_SAMPLE]
229
+
230
+ ids = shipment_ids.split(",")
231
+ shipments = manager_models.Shipment.objects.filter(id__in=ids)
232
+
233
+ return [
234
+ dict(
235
+ shipment=manager_serializers.Shipment(shipment).data,
236
+ line_items=get_shipment_item_contexts(shipment),
237
+ carrier=get_carrier_context(shipment.selected_rate_carrier),
238
+ orders=orders_serializers.Order(
239
+ get_shipment_order_contexts(shipment), many=True
240
+ ).data,
241
+ )
242
+ for shipment in shipments
243
+ ]
244
+
245
+
246
+ def get_shipment_item_contexts(shipment):
247
+ items = order_models.LineItem.objects.filter(
248
+ commodity_parcel__parcel_shipment=shipment
249
+ )
250
+ distinct_items = [
251
+ __ for _, __ in ({item.parent_id: item for item in items}).items()
252
+ ]
253
+
254
+ return [
255
+ {
256
+ **orders_serializers.LineItem(item.parent or item).data,
257
+ "ship_quantity": items.filter(parent_id=item.parent_id).aggregate(
258
+ Sum("quantity")
259
+ )["quantity__sum"],
260
+ "order": (orders_serializers.Order(item.order).data if item.order else {}),
261
+ }
262
+ for item in distinct_items
263
+ ]
264
+
265
+
266
+ def get_shipment_order_contexts(shipment):
267
+ return (
268
+ order_models.Order.objects.filter(
269
+ line_items__children__commodity_parcel__parcel_shipment=shipment
270
+ )
271
+ .order_by("-order_date")
272
+ .distinct()
273
+ )
274
+
275
+
276
+ def get_carrier_context(carrier=None):
277
+ if carrier is None:
278
+ return {}
279
+
280
+ return carrier.data.to_dict()
281
+
282
+
283
+ def get_orders_context(order_ids: str) -> typing.List[dict]:
284
+ if order_ids == "sample":
285
+ return [utils.ORDER_SAMPLE]
286
+
287
+ ids = order_ids.split(",")
288
+ orders = order_models.Order.objects.filter(id__in=ids)
289
+
290
+ return [
291
+ dict(
292
+ order=orders_serializers.Order(order).data,
293
+ )
294
+ for order in orders
295
+ ]
296
+
297
+
298
+ # endregion
@@ -0,0 +1,69 @@
1
+ # Generated by Django 3.2.11 on 2022-03-14 14:49
2
+
3
+ from django.conf import settings
4
+ import django.contrib.postgres.fields
5
+ import django.core.validators
6
+ from django.db import migrations, models
7
+ import django.db.models.deletion
8
+ import functools
9
+ import karrio.server.core.models.base
10
+
11
+
12
+ class Migration(migrations.Migration):
13
+
14
+ initial = True
15
+
16
+ dependencies = [
17
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
18
+ ]
19
+
20
+ operations = [
21
+ migrations.CreateModel(
22
+ name="DocumentTemplate",
23
+ fields=[
24
+ ("created_at", models.DateTimeField(auto_now_add=True)),
25
+ ("updated_at", models.DateTimeField(auto_now=True)),
26
+ (
27
+ "id",
28
+ models.CharField(
29
+ default=functools.partial(
30
+ karrio.server.core.models.base.uuid,
31
+ *(),
32
+ **{"prefix": "doc_"}
33
+ ),
34
+ editable=False,
35
+ max_length=50,
36
+ primary_key=True,
37
+ serialize=False,
38
+ ),
39
+ ),
40
+ (
41
+ "slug",
42
+ models.SlugField(
43
+ max_length=20,
44
+ validators=[
45
+ django.core.validators.RegexValidator("^[a-z0-9_]+$")
46
+ ],
47
+ ),
48
+ ),
49
+ ("name", models.CharField(max_length=50)),
50
+ ("template", models.TextField()),
51
+ ("description", models.CharField(blank=True, max_length=50, null=True)),
52
+ ("related_objects", models.JSONField(blank=True, null=True)),
53
+ (
54
+ "created_by",
55
+ models.ForeignKey(
56
+ on_delete=django.db.models.deletion.CASCADE,
57
+ to=settings.AUTH_USER_MODEL,
58
+ ),
59
+ ),
60
+ ],
61
+ options={
62
+ "verbose_name": "Document Template",
63
+ "verbose_name_plural": "Document Templates",
64
+ "db_table": "document-template",
65
+ "ordering": ["-created_at"],
66
+ },
67
+ bases=(karrio.server.core.models.base.ControlledAccessModel, models.Model),
68
+ ),
69
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 3.2.12 on 2022-03-21 20:47
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('documents', '0001_initial'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AlterField(
14
+ model_name='documenttemplate',
15
+ name='related_objects',
16
+ field=models.CharField(max_length=25),
17
+ ),
18
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 3.2.12 on 2022-03-21 20:47
2
+
3
+ from django.db import migrations
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('documents', '0002_alter_documenttemplate_related_objects'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.RenameField(
14
+ model_name='documenttemplate',
15
+ old_name='related_objects',
16
+ new_name='related_object',
17
+ ),
18
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 3.2.13 on 2022-06-18 09:09
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('documents', '0003_rename_related_objects_documenttemplate_related_object'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AddField(
14
+ model_name='documenttemplate',
15
+ name='active',
16
+ field=models.BooleanField(default=True, help_text='disable template flag. to filter out from active document downloads'),
17
+ ),
18
+ ]
@@ -0,0 +1,23 @@
1
+ # Generated by Django 4.1.3 on 2022-12-10 17:09
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ("documents", "0004_documenttemplate_active"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AlterField(
14
+ model_name="documenttemplate",
15
+ name="description",
16
+ field=models.CharField(blank=True, db_index=True, max_length=50, null=True),
17
+ ),
18
+ migrations.AlterField(
19
+ model_name="documenttemplate",
20
+ name="name",
21
+ field=models.CharField(db_index=True, max_length=50),
22
+ ),
23
+ ]
@@ -0,0 +1,25 @@
1
+ # Generated by Django 4.1.7 on 2023-02-22 15:35
2
+
3
+ from django.db import migrations, models
4
+ import functools
5
+ import karrio.server.core.models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+ dependencies = [
10
+ ("documents", "0005_alter_documenttemplate_description_and_more"),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AddField(
15
+ model_name="documenttemplate",
16
+ name="metadata",
17
+ field=models.JSONField(
18
+ blank=True,
19
+ default=functools.partial(
20
+ karrio.server.core.models._identity, *(), **{"value": {}}
21
+ ),
22
+ null=True,
23
+ ),
24
+ ),
25
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 4.2.11 on 2024-05-27 17:53
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ("documents", "0006_documenttemplate_metadata"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AlterField(
14
+ model_name="documenttemplate",
15
+ name="related_object",
16
+ field=models.CharField(blank=True, max_length=25, null=True),
17
+ ),
18
+ ]
@@ -0,0 +1,26 @@
1
+ # Generated by Django 4.2.16 on 2024-10-17 04:48
2
+
3
+ from django.db import migrations, models
4
+ import functools
5
+ import karrio.server.core.models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ dependencies = [
11
+ ("documents", "0007_alter_documenttemplate_related_object"),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.AddField(
16
+ model_name="documenttemplate",
17
+ name="options",
18
+ field=models.JSONField(
19
+ blank=True,
20
+ default=functools.partial(
21
+ karrio.server.core.models._identity, *(), **{"value": {}}
22
+ ),
23
+ null=True,
24
+ ),
25
+ ),
26
+ ]
File without changes