karrio-server-core 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-core might be problematic. Click here for more details.
- karrio/server/conf.py +54 -0
- karrio/server/core/__init__.py +3 -0
- karrio/server/core/admin.py +1 -0
- karrio/server/core/apps.py +10 -0
- karrio/server/core/authentication.py +313 -0
- karrio/server/core/context_processors.py +12 -0
- karrio/server/core/datatypes.py +369 -0
- karrio/server/core/dataunits.py +156 -0
- karrio/server/core/exceptions.py +200 -0
- karrio/server/core/fields.py +12 -0
- karrio/server/core/filters.py +823 -0
- karrio/server/core/gateway.py +720 -0
- karrio/server/core/management/commands/cli.py +19 -0
- karrio/server/core/management/commands/create_oauth_client.py +41 -0
- karrio/server/core/middleware.py +95 -0
- karrio/server/core/migrations/0001_initial.py +28 -0
- karrio/server/core/migrations/0002_apilogindex.py +69 -0
- karrio/server/core/migrations/0003_apilogindex_test_mode.py +62 -0
- karrio/server/core/migrations/0004_metafield.py +74 -0
- karrio/server/core/migrations/0005_alter_metafield_type_alter_metafield_value.py +23 -0
- karrio/server/core/migrations/__init__.py +0 -0
- karrio/server/core/models/__init__.py +48 -0
- karrio/server/core/models/base.py +70 -0
- karrio/server/core/models/entity.py +22 -0
- karrio/server/core/models/metafield.py +144 -0
- karrio/server/core/models/third_party.py +21 -0
- karrio/server/core/oauth_validators.py +171 -0
- karrio/server/core/permissions.py +37 -0
- karrio/server/core/renderers.py +11 -0
- karrio/server/core/router.py +3 -0
- karrio/server/core/serializers.py +1898 -0
- karrio/server/core/signals.py +57 -0
- karrio/server/core/tests.py +98 -0
- karrio/server/core/urls.py +12 -0
- karrio/server/core/utils.py +479 -0
- karrio/server/core/validators.py +416 -0
- karrio/server/core/views/__init__.py +2 -0
- karrio/server/core/views/api.py +133 -0
- karrio/server/core/views/metadata.py +44 -0
- karrio/server/core/views/oauth.py +74 -0
- karrio/server/core/views/references.py +82 -0
- karrio/server/core/views/schema.py +310 -0
- karrio/server/filters/__init__.py +2 -0
- karrio/server/filters/abstract.py +26 -0
- karrio/server/iam/__init__.py +0 -0
- karrio/server/iam/admin.py +3 -0
- karrio/server/iam/apps.py +21 -0
- karrio/server/iam/migrations/0001_initial.py +33 -0
- karrio/server/iam/migrations/__init__.py +0 -0
- karrio/server/iam/models.py +48 -0
- karrio/server/iam/permissions.py +134 -0
- karrio/server/iam/serializers.py +39 -0
- karrio/server/iam/signals.py +20 -0
- karrio/server/iam/tests.py +3 -0
- karrio/server/iam/views.py +3 -0
- karrio/server/openapi.py +75 -0
- karrio/server/providers/__init__.py +1 -0
- karrio/server/providers/admin.py +364 -0
- karrio/server/providers/apps.py +10 -0
- karrio/server/providers/extension/__init__.py +1 -0
- karrio/server/providers/extension/models/__init__.py +1 -0
- karrio/server/providers/extension/models/allied_express.py +22 -0
- karrio/server/providers/extension/models/allied_express_local.py +22 -0
- karrio/server/providers/extension/models/amazon_shipping.py +27 -0
- karrio/server/providers/extension/models/aramex.py +25 -0
- karrio/server/providers/extension/models/asendia_us.py +21 -0
- karrio/server/providers/extension/models/australiapost.py +20 -0
- karrio/server/providers/extension/models/boxknight.py +19 -0
- karrio/server/providers/extension/models/bpost.py +21 -0
- karrio/server/providers/extension/models/canadapost.py +21 -0
- karrio/server/providers/extension/models/canpar.py +19 -0
- karrio/server/providers/extension/models/chronopost.py +22 -0
- karrio/server/providers/extension/models/colissimo.py +22 -0
- karrio/server/providers/extension/models/dhl_express.py +23 -0
- karrio/server/providers/extension/models/dhl_parcel_de.py +25 -0
- karrio/server/providers/extension/models/dhl_poland.py +22 -0
- karrio/server/providers/extension/models/dhl_universal.py +19 -0
- karrio/server/providers/extension/models/dicom.py +20 -0
- karrio/server/providers/extension/models/dpd.py +37 -0
- karrio/server/providers/extension/models/dpdhl.py +26 -0
- karrio/server/providers/extension/models/easypost.py +20 -0
- karrio/server/providers/extension/models/eshipper.py +21 -0
- karrio/server/providers/extension/models/fedex.py +25 -0
- karrio/server/providers/extension/models/fedex_ws.py +24 -0
- karrio/server/providers/extension/models/freightcom.py +21 -0
- karrio/server/providers/extension/models/generic.py +35 -0
- karrio/server/providers/extension/models/geodis.py +22 -0
- karrio/server/providers/extension/models/hay_post.py +22 -0
- karrio/server/providers/extension/models/laposte.py +19 -0
- karrio/server/providers/extension/models/locate2u.py +22 -0
- karrio/server/providers/extension/models/nationex.py +22 -0
- karrio/server/providers/extension/models/purolator.py +21 -0
- karrio/server/providers/extension/models/roadie.py +18 -0
- karrio/server/providers/extension/models/royalmail.py +19 -0
- karrio/server/providers/extension/models/sendle.py +22 -0
- karrio/server/providers/extension/models/tge.py +63 -0
- karrio/server/providers/extension/models/tnt.py +23 -0
- karrio/server/providers/extension/models/ups.py +23 -0
- karrio/server/providers/extension/models/usps.py +23 -0
- karrio/server/providers/extension/models/usps_international.py +23 -0
- karrio/server/providers/extension/models/usps_wt.py +24 -0
- karrio/server/providers/extension/models/usps_wt_international.py +24 -0
- karrio/server/providers/extension/models/zoom2u.py +23 -0
- karrio/server/providers/migrations/0001_initial.py +140 -0
- karrio/server/providers/migrations/0002_carrier_active.py +18 -0
- karrio/server/providers/migrations/0003_auto_20201230_0820.py +24 -0
- karrio/server/providers/migrations/0004_auto_20210212_0554.py +178 -0
- karrio/server/providers/migrations/0005_auto_20210212_0555.py +18 -0
- karrio/server/providers/migrations/0006_australiapostsettings.py +29 -0
- karrio/server/providers/migrations/0007_auto_20210213_0206.py +21 -0
- karrio/server/providers/migrations/0008_auto_20210214_0409.py +30 -0
- karrio/server/providers/migrations/0009_auto_20210308_0302.py +18 -0
- karrio/server/providers/migrations/0010_auto_20210409_0852.py +32 -0
- karrio/server/providers/migrations/0011_auto_20210409_0853.py +21 -0
- karrio/server/providers/migrations/0012_alter_carrier_options.py +17 -0
- karrio/server/providers/migrations/0013_tntsettings.py +30 -0
- karrio/server/providers/migrations/0014_auto_20210612_1608.py +46 -0
- karrio/server/providers/migrations/0015_auto_20210615_1601.py +28 -0
- karrio/server/providers/migrations/0016_alter_purolatorsettings_user_token.py +18 -0
- karrio/server/providers/migrations/0017_auto_20210805_0359.py +1293 -0
- karrio/server/providers/migrations/0018_alter_fedexsettings_user_key.py +18 -0
- karrio/server/providers/migrations/0019_dhlpolandsettings_servicelevel.py +65 -0
- karrio/server/providers/migrations/0020_genericsettings_labeltemplate.py +52 -0
- karrio/server/providers/migrations/0021_auto_20211231_2353.py +40 -0
- karrio/server/providers/migrations/0022_carrier_metadata.py +18 -0
- karrio/server/providers/migrations/0023_auto_20220124_1916.py +27 -0
- karrio/server/providers/migrations/0024_alter_genericsettings_custom_carrier_name.py +19 -0
- karrio/server/providers/migrations/0025_alter_servicelevel_service_code.py +19 -0
- karrio/server/providers/migrations/0026_auto_20220208_0132.py +59 -0
- karrio/server/providers/migrations/0027_auto_20220304_1340.py +29 -0
- karrio/server/providers/migrations/0028_auto_20220323_1500.py +33 -0
- karrio/server/providers/migrations/0029_easypostsettings.py +27 -0
- karrio/server/providers/migrations/0030_amazonmwssettings.py +29 -0
- karrio/server/providers/migrations/0031_delete_amazonmwssettings.py +18 -0
- karrio/server/providers/migrations/0032_alter_carrier_test.py +18 -0
- karrio/server/providers/migrations/0033_auto_20220708_1350.py +22 -0
- karrio/server/providers/migrations/0034_amazonmwssettings_dpdhlsettings.py +47 -0
- karrio/server/providers/migrations/0035_alter_carrier_capabilities.py +43 -0
- karrio/server/providers/migrations/0036_upsfreightsettings.py +31 -0
- karrio/server/providers/migrations/0037_chronopostsettings.py +29 -0
- karrio/server/providers/migrations/0038_alter_genericsettings_label_template.py +19 -0
- karrio/server/providers/migrations/0039_auto_20220906_0612.py +23 -0
- karrio/server/providers/migrations/0040_dpdhlsettings_services.py +18 -0
- karrio/server/providers/migrations/0041_auto_20221105_0705.py +38 -0
- karrio/server/providers/migrations/0042_auto_20221215_1642.py +23 -0
- karrio/server/providers/migrations/0043_alter_genericsettings_account_number_and_more.py +39 -0
- karrio/server/providers/migrations/0044_carrier_carrier_capabilities.py +64 -0
- karrio/server/providers/migrations/0045_alter_carrier_active_alter_carrier_carrier_id.py +31 -0
- karrio/server/providers/migrations/0046_remove_dpdhlsettings_signature_and_more.py +41 -0
- karrio/server/providers/migrations/0047_dpdsettings.py +286 -0
- karrio/server/providers/migrations/0048_servicelevel_min_weight_servicelevel_transit_days_and_more.py +64 -0
- karrio/server/providers/migrations/0049_boxknightsettings_geodissettings_lapostesettings_and_more.py +156 -0
- karrio/server/providers/migrations/0050_carrier_is_system_alter_carrier_metadata_and_more.py +106 -0
- karrio/server/providers/migrations/0051_rename_username_upssettings_client_id_and_more.py +31 -0
- karrio/server/providers/migrations/0052_alter_upssettings_account_number_and_more.py +20 -0
- karrio/server/providers/migrations/0053_locate2usettings.py +281 -0
- karrio/server/providers/migrations/0054_zoom2usettings.py +280 -0
- karrio/server/providers/migrations/0055_rename_amazonmwssettings_amazonshippingsettings_and_more.py +44 -0
- karrio/server/providers/migrations/0056_asendiaussettings_geodissettings_code_client_and_more.py +75 -0
- karrio/server/providers/migrations/0057_alter_servicelevel_weight_unit_belgianpostsettings.py +51 -0
- karrio/server/providers/migrations/0058_alliedexpresssettings.py +38 -0
- karrio/server/providers/migrations/0059_ratesheet.py +81 -0
- karrio/server/providers/migrations/0060_belgianpostsettings_rate_sheet_and_more.py +73 -0
- karrio/server/providers/migrations/0061_alliedexpresssettings_service_type.py +17 -0
- karrio/server/providers/migrations/0062_sendlesettings_account_country_code.py +257 -0
- karrio/server/providers/migrations/0063_servicelevel_metadata.py +25 -0
- karrio/server/providers/migrations/0064_alliedexpresslocalsettings.py +43 -0
- karrio/server/providers/migrations/0065_servicelevel_carrier_service_code_and_more.py +66 -0
- karrio/server/providers/migrations/0066_rename_fedexsettings_fedexwssettings_and_more.py +28 -0
- karrio/server/providers/migrations/0067_fedexsettings.py +283 -0
- karrio/server/providers/migrations/0068_fedexsettings_track_api_key_and_more.py +38 -0
- karrio/server/providers/migrations/0069_alter_canadapostsettings_contract_id_and_more.py +23 -0
- karrio/server/providers/migrations/0070_tgesettings_alter_carrier_capabilities.py +65 -0
- karrio/server/providers/migrations/0071_alter_tgesettings_my_toll_token.py +18 -0
- karrio/server/providers/migrations/0072_rename_eshippersettings_eshipperxmlsettings_and_more.py +28 -0
- karrio/server/providers/migrations/0073_delete_eshipperxmlsettings.py +41 -0
- karrio/server/providers/migrations/0074_eshippersettings.py +38 -0
- karrio/server/providers/migrations/0075_haypostsettings.py +40 -0
- karrio/server/providers/migrations/0076_rename_customer_registration_id_uspsinternationalsettings_account_number_and_more.py +125 -0
- karrio/server/providers/migrations/0077_uspswtinternationalsettings_uspswtsettings_and_more.py +165 -0
- karrio/server/providers/migrations/0078_auto_20240813_1552.py +120 -0
- karrio/server/providers/migrations/0079_alter_carrier_options_alter_ratesheet_created_by.py +31 -0
- karrio/server/providers/migrations/0080_alter_aramexsettings_account_country_code_and_more.py +3025 -0
- karrio/server/providers/migrations/0081_remove_alliedexpresssettings_carrier_ptr_and_more.py +338 -0
- karrio/server/providers/migrations/__init__.py +0 -0
- karrio/server/providers/models/__init__.py +17 -0
- karrio/server/providers/models/carrier.py +309 -0
- karrio/server/providers/models/config.py +30 -0
- karrio/server/providers/models/service.py +62 -0
- karrio/server/providers/models/sheet.py +60 -0
- karrio/server/providers/models/template.py +39 -0
- karrio/server/providers/models/utils.py +58 -0
- karrio/server/providers/router.py +3 -0
- karrio/server/providers/serializers/__init__.py +3 -0
- karrio/server/providers/serializers/base.py +277 -0
- karrio/server/providers/signals.py +27 -0
- karrio/server/providers/tests.py +3 -0
- karrio/server/providers/urls.py +11 -0
- karrio/server/providers/views/__init__.py +0 -0
- karrio/server/providers/views/carriers.py +269 -0
- karrio/server/providers/views/connections.py +176 -0
- karrio/server/samples.py +352 -0
- karrio/server/serializers/__init__.py +2 -0
- karrio/server/serializers/abstract.py +506 -0
- karrio/server/tracing/__init__.py +0 -0
- karrio/server/tracing/admin.py +63 -0
- karrio/server/tracing/apps.py +8 -0
- karrio/server/tracing/migrations/0001_initial.py +41 -0
- karrio/server/tracing/migrations/0002_auto_20220710_1307.py +22 -0
- karrio/server/tracing/migrations/0003_auto_20221105_0317.py +43 -0
- karrio/server/tracing/migrations/0004_tracingrecord_carrier_account_idx.py +24 -0
- karrio/server/tracing/migrations/0005_optimise_tracingrecord_request_log_idx.py +25 -0
- karrio/server/tracing/migrations/0006_alter_tracingrecord_options_and_more.py +49 -0
- karrio/server/tracing/migrations/__init__.py +0 -0
- karrio/server/tracing/models.py +80 -0
- karrio/server/tracing/tests.py +3 -0
- karrio/server/tracing/utils.py +112 -0
- karrio/server/user/__init__.py +0 -0
- karrio/server/user/admin.py +96 -0
- karrio/server/user/apps.py +7 -0
- karrio/server/user/forms.py +35 -0
- karrio/server/user/migrations/0001_initial.py +41 -0
- karrio/server/user/migrations/0002_token.py +29 -0
- karrio/server/user/migrations/0003_token_test_mode.py +20 -0
- karrio/server/user/migrations/0004_group.py +26 -0
- karrio/server/user/migrations/0005_token_label.py +21 -0
- karrio/server/user/migrations/0006_workspaceconfig.py +63 -0
- karrio/server/user/migrations/__init__.py +0 -0
- karrio/server/user/models.py +203 -0
- karrio/server/user/serializers.py +46 -0
- karrio/server/user/templates/registration/login.html +108 -0
- karrio/server/user/templates/registration/registration_confirm_email.html +10 -0
- karrio/server/user/templates/registration/registration_confirm_email.txt +3 -0
- karrio/server/user/tests.py +3 -0
- karrio/server/user/urls.py +10 -0
- karrio/server/user/utils.py +60 -0
- karrio/server/user/views.py +9 -0
- karrio_server_core-2025.5rc1.dist-info/METADATA +32 -0
- karrio_server_core-2025.5rc1.dist-info/RECORD +241 -0
- karrio_server_core-2025.5rc1.dist-info/WHEEL +5 -0
- karrio_server_core-2025.5rc1.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
import pydoc
|
|
3
|
+
import typing
|
|
4
|
+
import logging
|
|
5
|
+
from django.db import models
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
from django.db import transaction
|
|
8
|
+
from rest_framework import serializers
|
|
9
|
+
from django.forms.models import model_to_dict
|
|
10
|
+
from drf_spectacular.types import OpenApiTypes
|
|
11
|
+
|
|
12
|
+
import karrio.lib as lib
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
T = typing.TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Context(typing.NamedTuple):
|
|
19
|
+
user: typing.Any
|
|
20
|
+
org: typing.Any = None
|
|
21
|
+
test_mode: bool = None
|
|
22
|
+
|
|
23
|
+
def __getitem__(self, item):
|
|
24
|
+
return getattr(self, item)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DecoratedSerializer:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
instance: models.Model = None,
|
|
31
|
+
serializer: "Serializer" = None,
|
|
32
|
+
):
|
|
33
|
+
self._instance = instance
|
|
34
|
+
self._serializer = serializer
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def data(self) -> typing.Optional[dict]:
|
|
38
|
+
return self._serializer.validated_data if self._serializer is not None else None
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def instance(self) -> models.Model:
|
|
42
|
+
return self._instance
|
|
43
|
+
|
|
44
|
+
def save(self, **kwargs) -> "DecoratedSerializer":
|
|
45
|
+
if self._serializer is not None:
|
|
46
|
+
self._instance = self._serializer.save(**kwargs)
|
|
47
|
+
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AbstractSerializer:
|
|
52
|
+
def create(self, validated_data, **kwargs):
|
|
53
|
+
super().create(validated_data)
|
|
54
|
+
|
|
55
|
+
def update(self, instance, validated_data, **kwargs):
|
|
56
|
+
super().update(instance, validated_data, **kwargs)
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def map(
|
|
60
|
+
cls, instance=None, data: typing.Union[str, dict] = None, **kwargs
|
|
61
|
+
) -> "DecoratedSerializer":
|
|
62
|
+
if data is None and instance is None:
|
|
63
|
+
serializer = None
|
|
64
|
+
else:
|
|
65
|
+
serializer = (
|
|
66
|
+
cls(data=data or {}, **kwargs) # type:ignore
|
|
67
|
+
if instance is None
|
|
68
|
+
else cls(
|
|
69
|
+
instance, data=data or {}, **{**kwargs, "partial": True}
|
|
70
|
+
) # type:ignore
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
serializer.is_valid(raise_exception=True) # type:ignore
|
|
74
|
+
|
|
75
|
+
return DecoratedSerializer(
|
|
76
|
+
instance=instance,
|
|
77
|
+
serializer=serializer, # type:ignore
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Serializer(serializers.Serializer, AbstractSerializer):
|
|
82
|
+
context: dict = {}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ModelSerializer(serializers.ModelSerializer, AbstractSerializer):
|
|
86
|
+
def create(self, data: dict, **kwargs): # type: ignore
|
|
87
|
+
return self.Meta.model.objects.create(**data)
|
|
88
|
+
|
|
89
|
+
def update(self, instance, data: dict, **kwargs): # type: ignore
|
|
90
|
+
for name, value in data.items():
|
|
91
|
+
if name != "created_by" and hasattr(instance, name):
|
|
92
|
+
setattr(instance, name, value)
|
|
93
|
+
|
|
94
|
+
instance.save()
|
|
95
|
+
return instance
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class StringListField(serializers.ListField):
|
|
99
|
+
child = serializers.CharField()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class PlainDictField(serializers.DictField):
|
|
103
|
+
class Meta:
|
|
104
|
+
swagger_schema_fields = {
|
|
105
|
+
"type": OpenApiTypes.OBJECT,
|
|
106
|
+
"additional_properties": True,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class FlagField(serializers.BooleanField):
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class FlagsSerializer(serializers.Serializer):
|
|
115
|
+
def __init__(self, *args, **kwargs):
|
|
116
|
+
data = kwargs.get("data", {})
|
|
117
|
+
self.flags = [
|
|
118
|
+
(label, label in data)
|
|
119
|
+
for label, field in self.fields.items()
|
|
120
|
+
if isinstance(field, FlagField)
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
super().__init__(*args, **kwargs)
|
|
124
|
+
|
|
125
|
+
def validate(self, data):
|
|
126
|
+
validated = super().validate(data)
|
|
127
|
+
for flag, specified in self.flags:
|
|
128
|
+
if specified and validated[flag] is None:
|
|
129
|
+
validated.update({flag: True})
|
|
130
|
+
|
|
131
|
+
return validated
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class EntitySerializer(serializers.Serializer):
|
|
135
|
+
id = serializers.CharField(required=False, help_text="A unique identifier")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
"""
|
|
139
|
+
Custom serializer utilities functions
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def PaginatedResult(serializer_name: str, content_serializer: typing.Type[Serializer]):
|
|
144
|
+
return type(
|
|
145
|
+
serializer_name,
|
|
146
|
+
(Serializer,),
|
|
147
|
+
dict(
|
|
148
|
+
count=serializers.IntegerField(required=False, allow_null=True),
|
|
149
|
+
next=serializers.URLField(
|
|
150
|
+
required=False, allow_blank=True, allow_null=True
|
|
151
|
+
),
|
|
152
|
+
previous=serializers.URLField(
|
|
153
|
+
required=False, allow_blank=True, allow_null=True
|
|
154
|
+
),
|
|
155
|
+
results=content_serializer(many=True),
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def owned_model_serializer(serializer: typing.Type[Serializer]):
|
|
161
|
+
class MetaSerializer(serializer): # type: ignore
|
|
162
|
+
context: dict = {}
|
|
163
|
+
|
|
164
|
+
def __init__(self, *args, **kwargs):
|
|
165
|
+
if "context" in kwargs:
|
|
166
|
+
context = kwargs.get("context") or {}
|
|
167
|
+
user = (
|
|
168
|
+
context.get("user") if isinstance(context, dict) else context.user
|
|
169
|
+
)
|
|
170
|
+
org = context.get("org") if isinstance(context, dict) else context.org
|
|
171
|
+
test_mode = (
|
|
172
|
+
context.get("test_mode")
|
|
173
|
+
if isinstance(context, dict)
|
|
174
|
+
else context.test_mode
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if settings.MULTI_ORGANIZATIONS and org is None:
|
|
178
|
+
import karrio.server.orgs.models as orgs
|
|
179
|
+
|
|
180
|
+
org = orgs.Organization.objects.filter(
|
|
181
|
+
users__id=getattr(user, "id", None)
|
|
182
|
+
).first()
|
|
183
|
+
|
|
184
|
+
self.__context: Context = Context(user, org, test_mode)
|
|
185
|
+
else:
|
|
186
|
+
self.__context: Context = getattr(self, "__context", None)
|
|
187
|
+
kwargs.update({"context": self.__context})
|
|
188
|
+
|
|
189
|
+
super().__init__(*args, **kwargs)
|
|
190
|
+
|
|
191
|
+
@transaction.atomic
|
|
192
|
+
def create(self, data: dict, **kwargs):
|
|
193
|
+
payload = {"created_by": self.__context.user, **data}
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
instance = super().create(payload, context=self.__context)
|
|
197
|
+
link_org(instance, self.__context) # Link to organization if supported
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.exception(e)
|
|
200
|
+
raise e
|
|
201
|
+
|
|
202
|
+
return instance
|
|
203
|
+
|
|
204
|
+
def update(self, instance, data: dict, **kwargs):
|
|
205
|
+
payload = {k: v for k, v in data.items()}
|
|
206
|
+
|
|
207
|
+
return super().update(instance, payload, context=self.__context)
|
|
208
|
+
|
|
209
|
+
return type(serializer.__name__, (MetaSerializer,), {})
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def link_org(entity: ModelSerializer, context: Context):
|
|
213
|
+
if (
|
|
214
|
+
context.org is not None
|
|
215
|
+
and hasattr(entity, "org")
|
|
216
|
+
and hasattr(entity.org, "exists")
|
|
217
|
+
and not entity.org.exists()
|
|
218
|
+
):
|
|
219
|
+
entity.link = entity.__class__.link.related.related_model.objects.create(
|
|
220
|
+
org=context.org, item=entity
|
|
221
|
+
)
|
|
222
|
+
entity.save(
|
|
223
|
+
update_fields=(["created_at"] if hasattr(entity, "created_at") else [])
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def bulk_link_org(entities: typing.List[models.Model], context: Context):
|
|
227
|
+
if len(entities) == 0 or settings.MULTI_ORGANIZATIONS is False:
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
EntityLinkModel = entities[0].__class__.link.related.related_model
|
|
231
|
+
links = []
|
|
232
|
+
|
|
233
|
+
for entity in entities:
|
|
234
|
+
entity.link = EntityLinkModel(org=context.org, item=entity)
|
|
235
|
+
links.append(entity.link)
|
|
236
|
+
|
|
237
|
+
EntityLinkModel.objects.bulk_create(links)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def get_object_context(entity) -> Context:
|
|
241
|
+
org = lib.failsafe(
|
|
242
|
+
lambda: (
|
|
243
|
+
entity.org.first()
|
|
244
|
+
if (hasattr(entity, "org") and entity.org.exists())
|
|
245
|
+
else None
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return Context(
|
|
250
|
+
org=org,
|
|
251
|
+
user=getattr(entity, "created_by", None),
|
|
252
|
+
test_mode=getattr(entity, "test_mode", None),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def save_many_to_many_data(
|
|
257
|
+
name: str,
|
|
258
|
+
serializer: ModelSerializer,
|
|
259
|
+
parent: models.Model,
|
|
260
|
+
payload: dict = None,
|
|
261
|
+
remove_if_missing: bool = False,
|
|
262
|
+
**kwargs,
|
|
263
|
+
):
|
|
264
|
+
if not any((key in payload for key in [name])):
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
collection_data = payload.get(name)
|
|
268
|
+
collection = getattr(parent, name)
|
|
269
|
+
|
|
270
|
+
if collection_data is None and any(collection.all()):
|
|
271
|
+
for item in collection.all():
|
|
272
|
+
item.delete()
|
|
273
|
+
|
|
274
|
+
if remove_if_missing and collection.exists():
|
|
275
|
+
collection.exclude(id__in=[item.get("id") for item in collection_data]).delete()
|
|
276
|
+
|
|
277
|
+
for data in collection_data:
|
|
278
|
+
item_instance = (
|
|
279
|
+
collection.filter(id=data.pop("id")).first() if "id" in data else None
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
if item_instance is None:
|
|
283
|
+
item = serializer.map(data=data, **kwargs).save().instance
|
|
284
|
+
getattr(parent, name).add(item)
|
|
285
|
+
else:
|
|
286
|
+
item = (
|
|
287
|
+
serializer.map(
|
|
288
|
+
data=data,
|
|
289
|
+
instance=item_instance,
|
|
290
|
+
**{**kwargs, "partial": True},
|
|
291
|
+
)
|
|
292
|
+
.save()
|
|
293
|
+
.instance
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def save_one_to_one_data(
|
|
298
|
+
name: str,
|
|
299
|
+
serializer: ModelSerializer,
|
|
300
|
+
parent: models.Model = None,
|
|
301
|
+
payload: dict = None,
|
|
302
|
+
**kwargs,
|
|
303
|
+
):
|
|
304
|
+
if name not in payload:
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
data = payload.get(name)
|
|
308
|
+
instance = getattr(parent, name, None)
|
|
309
|
+
|
|
310
|
+
if data is None and instance is not None:
|
|
311
|
+
instance.delete()
|
|
312
|
+
setattr(parent, name, None)
|
|
313
|
+
|
|
314
|
+
if instance is None:
|
|
315
|
+
new_instance = serializer.map(data=data, **kwargs).save().instance
|
|
316
|
+
parent and setattr(parent, name, new_instance) # type: ignore
|
|
317
|
+
return new_instance
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
serializer.map(instance=instance, data=data, **{**kwargs, "partial": True})
|
|
321
|
+
.save()
|
|
322
|
+
.instance
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def allow_model_id(model_paths: []): # type: ignore
|
|
327
|
+
def _decorator(serializer: typing.Type[Serializer]):
|
|
328
|
+
class ModelIdSerializer(serializer): # type: ignore
|
|
329
|
+
def __init__(self, *args, **kwargs):
|
|
330
|
+
for param, model_path in model_paths:
|
|
331
|
+
content = kwargs.get("data", {}).get(param)
|
|
332
|
+
values = content if isinstance(content, list) else [content]
|
|
333
|
+
model = pydoc.locate(model_path)
|
|
334
|
+
|
|
335
|
+
if any([isinstance(val, dict) and "id" in val for val in values]):
|
|
336
|
+
new_content = []
|
|
337
|
+
for value in values:
|
|
338
|
+
if (
|
|
339
|
+
isinstance(value, dict)
|
|
340
|
+
and ("id" in value)
|
|
341
|
+
and (model is not None)
|
|
342
|
+
):
|
|
343
|
+
data = model_to_dict(model.objects.get(pk=value["id"]))
|
|
344
|
+
|
|
345
|
+
for field, field_data in data.items():
|
|
346
|
+
if isinstance(field_data, list):
|
|
347
|
+
data[field] = [
|
|
348
|
+
(
|
|
349
|
+
model_to_dict(item)
|
|
350
|
+
if hasattr(item, "_meta")
|
|
351
|
+
else item
|
|
352
|
+
)
|
|
353
|
+
for item in field_data
|
|
354
|
+
]
|
|
355
|
+
|
|
356
|
+
if hasattr(field_data, "_meta"):
|
|
357
|
+
data[field] = model_to_dict(field_data)
|
|
358
|
+
|
|
359
|
+
("id" in data) and data.pop("id")
|
|
360
|
+
new_content.append(data)
|
|
361
|
+
|
|
362
|
+
kwargs.update(
|
|
363
|
+
data={
|
|
364
|
+
**kwargs["data"],
|
|
365
|
+
param: (
|
|
366
|
+
new_content
|
|
367
|
+
if isinstance(content, list)
|
|
368
|
+
else next(iter(new_content))
|
|
369
|
+
),
|
|
370
|
+
}
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
super().__init__(*args, **kwargs)
|
|
374
|
+
|
|
375
|
+
return type(serializer.__name__, (ModelIdSerializer,), {})
|
|
376
|
+
|
|
377
|
+
return _decorator
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def make_fields_optional(serializer: typing.Type[ModelSerializer]):
|
|
381
|
+
_name = f"Partial{serializer.__name__}"
|
|
382
|
+
|
|
383
|
+
class _Meta(serializer.Meta): # type: ignore
|
|
384
|
+
extra_kwargs = {
|
|
385
|
+
**getattr(serializer.Meta, "extra_kwargs", {}),
|
|
386
|
+
**{
|
|
387
|
+
field.name: {"required": False}
|
|
388
|
+
for field in serializer.Meta.model._meta.fields
|
|
389
|
+
},
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return type(_name, (serializer,), dict(Meta=_Meta))
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def exclude_id_field(serializer: typing.Type[ModelSerializer]):
|
|
396
|
+
class _Meta(serializer.Meta): # type: ignore
|
|
397
|
+
exclude = [*getattr(serializer.Meta, "exclude", []), "id"]
|
|
398
|
+
|
|
399
|
+
return type(serializer.__name__, (serializer,), dict(Meta=_Meta))
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def is_field_optional(model, field_name: str) -> bool:
|
|
403
|
+
field = getattr(model, field_name)
|
|
404
|
+
|
|
405
|
+
if hasattr(field, "field"):
|
|
406
|
+
return field.field.null
|
|
407
|
+
|
|
408
|
+
return False
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def process_dictionaries_mutations(
|
|
412
|
+
keys: typing.List[str], payload: dict, entity
|
|
413
|
+
) -> dict:
|
|
414
|
+
"""This function checks if the payload contains dictionary with the keys and if so, it
|
|
415
|
+
mutate the values content by removing any null values and adding the new one.
|
|
416
|
+
"""
|
|
417
|
+
data = payload.copy()
|
|
418
|
+
|
|
419
|
+
for key in [k for k in keys if k in payload]:
|
|
420
|
+
value = lib.to_dict({**getattr(entity, key, {}), **payload.get(key, {})})
|
|
421
|
+
data.update({key: value})
|
|
422
|
+
|
|
423
|
+
return data
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def get_query_flag(
|
|
427
|
+
key: str,
|
|
428
|
+
query_params: dict,
|
|
429
|
+
nullable: bool = True,
|
|
430
|
+
) -> typing.Optional[bool]:
|
|
431
|
+
_value = yaml.safe_load(query_params.get(key) or "")
|
|
432
|
+
|
|
433
|
+
if key in query_params and _value is not False:
|
|
434
|
+
return True
|
|
435
|
+
|
|
436
|
+
if nullable:
|
|
437
|
+
return _value
|
|
438
|
+
|
|
439
|
+
return False
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def field_to_serializer(args: dict):
|
|
443
|
+
[type, name, required, default, enum] = [
|
|
444
|
+
args.get("type"),
|
|
445
|
+
args.get("name"),
|
|
446
|
+
args.get("required"),
|
|
447
|
+
args.get("default"),
|
|
448
|
+
args.get("enum"),
|
|
449
|
+
]
|
|
450
|
+
|
|
451
|
+
if enum:
|
|
452
|
+
return serializers.ChoiceField(
|
|
453
|
+
choices=enum,
|
|
454
|
+
required=required,
|
|
455
|
+
help_text=f"Indicates a {name} {type}",
|
|
456
|
+
)
|
|
457
|
+
if type == "string":
|
|
458
|
+
return serializers.CharField(
|
|
459
|
+
required=required,
|
|
460
|
+
**(dict(default=default) if not required else {}),
|
|
461
|
+
)
|
|
462
|
+
if type == "integer":
|
|
463
|
+
return serializers.IntegerField(
|
|
464
|
+
required=required,
|
|
465
|
+
**(dict(default=default) if not required else {}),
|
|
466
|
+
)
|
|
467
|
+
if type == "boolean":
|
|
468
|
+
return serializers.BooleanField(
|
|
469
|
+
required=required,
|
|
470
|
+
**(dict(default=default) if not required else {}),
|
|
471
|
+
)
|
|
472
|
+
if type == "float":
|
|
473
|
+
return serializers.FloatField(
|
|
474
|
+
required=required,
|
|
475
|
+
**(dict(default=default) if not required else {}),
|
|
476
|
+
)
|
|
477
|
+
if type == "datetime":
|
|
478
|
+
return serializers.DateTimeField(
|
|
479
|
+
required=required,
|
|
480
|
+
**(dict(default=default) if not required else {}),
|
|
481
|
+
)
|
|
482
|
+
if type == "date":
|
|
483
|
+
return serializers.DateField(
|
|
484
|
+
required=required,
|
|
485
|
+
**(dict(default=default) if not required else {}),
|
|
486
|
+
)
|
|
487
|
+
if type == "decimal":
|
|
488
|
+
return serializers.DecimalField(
|
|
489
|
+
required=required,
|
|
490
|
+
**(dict(default=default) if not required else {}),
|
|
491
|
+
)
|
|
492
|
+
if type == "uuid":
|
|
493
|
+
return serializers.UUIDField(
|
|
494
|
+
required=required,
|
|
495
|
+
**(dict(default=default) if not required else {}),
|
|
496
|
+
)
|
|
497
|
+
if type == "email":
|
|
498
|
+
return serializers.EmailField(
|
|
499
|
+
required=required,
|
|
500
|
+
**(dict(default=default) if not required else {}),
|
|
501
|
+
)
|
|
502
|
+
if type == "url":
|
|
503
|
+
return serializers.URLField(
|
|
504
|
+
required=required,
|
|
505
|
+
**(dict(default=default) if not required else {}),
|
|
506
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
from django.urls import reverse
|
|
3
|
+
from django.contrib import admin
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from django.utils.safestring import mark_safe
|
|
6
|
+
from django.utils.translation import gettext_lazy as _
|
|
7
|
+
from rest_framework_tracking.admin import APIRequestLog
|
|
8
|
+
|
|
9
|
+
from karrio.server.tracing import models
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TracingRecordAdmin(admin.ModelAdmin):
|
|
13
|
+
list_display = ("id", "log", "key", "test_mode", "request_timestamp", "created_at")
|
|
14
|
+
search_fields = ("meta__request_log_id", "meta__carrier_name")
|
|
15
|
+
list_filter = ("key", "test_mode")
|
|
16
|
+
readonly_fields = [
|
|
17
|
+
f.name
|
|
18
|
+
for f in models.TracingRecord._meta.get_fields()
|
|
19
|
+
if f.name not in ["org", "link"]
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
def get_queryset(self, request):
|
|
23
|
+
if settings.MULTI_ORGANIZATIONS:
|
|
24
|
+
return (
|
|
25
|
+
models.TracingRecord.objects
|
|
26
|
+
.all()
|
|
27
|
+
.filter(link__org__users__id=request.user.id)
|
|
28
|
+
.order_by("-timestamp")
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return super().get_queryset(request).order_by("-timestamp")
|
|
32
|
+
|
|
33
|
+
def has_add_permission(self, request) -> bool:
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
def log(self, obj):
|
|
37
|
+
log_id = obj.meta.get("request_log_id")
|
|
38
|
+
|
|
39
|
+
if any(str(log_id)):
|
|
40
|
+
return mark_safe(
|
|
41
|
+
'<a href="{}">{}</a>'.format(
|
|
42
|
+
reverse(
|
|
43
|
+
f"admin:{APIRequestLog._meta.app_label}_{APIRequestLog._meta.model_name}_change",
|
|
44
|
+
args=(log_id,),
|
|
45
|
+
),
|
|
46
|
+
log_id,
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
def request_timestamp(self, obj):
|
|
53
|
+
timestamp = datetime.datetime.fromtimestamp(obj.timestamp)
|
|
54
|
+
|
|
55
|
+
if timestamp:
|
|
56
|
+
return timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
|
57
|
+
|
|
58
|
+
return ""
|
|
59
|
+
|
|
60
|
+
request_timestamp.admin_order_field = "timestamp"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
admin.site.register(models.TracingRecord, TracingRecordAdmin)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Generated by Django 3.2.13 on 2022-06-28 00:44
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
import django.db.models.deletion
|
|
6
|
+
import functools
|
|
7
|
+
import karrio.core.utils.helpers
|
|
8
|
+
import karrio.server.core.models.base
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Migration(migrations.Migration):
|
|
12
|
+
|
|
13
|
+
initial = True
|
|
14
|
+
|
|
15
|
+
dependencies = [
|
|
16
|
+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
operations = [
|
|
20
|
+
migrations.CreateModel(
|
|
21
|
+
name='TracingRecord',
|
|
22
|
+
fields=[
|
|
23
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
24
|
+
('updated_at', models.DateTimeField(auto_now=True)),
|
|
25
|
+
('id', models.CharField(default=functools.partial(karrio.server.core.models.base.uuid, *(), **{'prefix': 'trace_'}), editable=False, max_length=50, primary_key=True, serialize=False)),
|
|
26
|
+
('key', models.CharField(max_length=50)),
|
|
27
|
+
('record', models.JSONField(default=functools.partial(karrio.core.utils.helpers.identity, *(), **{'value': {}}), help_text='Record data')),
|
|
28
|
+
('timestamp', models.FloatField()),
|
|
29
|
+
('meta', models.JSONField(blank=True, default=functools.partial(karrio.core.utils.helpers.identity, *(), **{'value': {}}), help_text='Readonly Context metadata use for filtering and premission check', null=True)),
|
|
30
|
+
('test_mode', models.BooleanField()),
|
|
31
|
+
('created_by', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
|
32
|
+
],
|
|
33
|
+
options={
|
|
34
|
+
'verbose_name': 'Tracing Record',
|
|
35
|
+
'verbose_name_plural': 'Tracing Records',
|
|
36
|
+
'db_table': 'tracing-record',
|
|
37
|
+
'ordering': ['-created_at'],
|
|
38
|
+
},
|
|
39
|
+
bases=(karrio.server.core.models.base.ControlledAccessModel, models.Model),
|
|
40
|
+
),
|
|
41
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Generated by Django 3.2.13 on 2022-07-10 13:07
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
import django.db.models.fields.json
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
dependencies = [
|
|
10
|
+
('tracing', '0001_initial'),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.AddIndex(
|
|
15
|
+
model_name='tracingrecord',
|
|
16
|
+
index=models.Index(django.db.models.fields.json.KeyTextTransform('object_id', 'meta'), condition=models.Q(('meta__object_id__isnull', False)), name='trace_object_idx'),
|
|
17
|
+
),
|
|
18
|
+
migrations.AddIndex(
|
|
19
|
+
model_name='tracingrecord',
|
|
20
|
+
index=models.Index(django.db.models.fields.json.KeyTextTransform('request_log_id', 'meta'), condition=models.Q(('meta__request_log_id__isnull', False)), name='request_log_idx'),
|
|
21
|
+
),
|
|
22
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Generated by Django 3.2.16 on 2022-11-05 03:17
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def forwards_func(apps, schema_editor):
|
|
7
|
+
db_alias = schema_editor.connection.alias
|
|
8
|
+
TracingRecord = apps.get_model("tracing", "TracingRecord")
|
|
9
|
+
|
|
10
|
+
duplicates = TracingRecord.objects.using(db_alias)\
|
|
11
|
+
.values('meta__request_log_id')\
|
|
12
|
+
.annotate(count=models.Count('id'))\
|
|
13
|
+
.values('meta__request_log_id')\
|
|
14
|
+
.order_by().filter(count__gt=1)
|
|
15
|
+
|
|
16
|
+
for value in duplicates:
|
|
17
|
+
dups = TracingRecord.objects.using(db_alias)\
|
|
18
|
+
.filter(meta__request_log_id=value['meta__request_log_id'])
|
|
19
|
+
|
|
20
|
+
if dups.filter(key='response').exists():
|
|
21
|
+
dups.filter(key='response').exclude(
|
|
22
|
+
id__in=[dups.filter(key='response').first().id]
|
|
23
|
+
).delete()
|
|
24
|
+
|
|
25
|
+
if dups.filter(key='request').exists():
|
|
26
|
+
dups.filter(key='request').exclude(
|
|
27
|
+
id__in=[dups.filter(key='request').first().id],
|
|
28
|
+
).delete()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def reverse_func(apps, schema_editor):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
class Migration(migrations.Migration):
|
|
36
|
+
|
|
37
|
+
dependencies = [
|
|
38
|
+
('tracing', '0002_auto_20220710_1307'),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
operations = [
|
|
42
|
+
migrations.RunPython(forwards_func, reverse_func),
|
|
43
|
+
]
|