karrio-server-events 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.
- karrio/server/events/__init__.py +3 -0
- karrio/server/events/admin.py +3 -0
- karrio/server/events/apps.py +11 -0
- karrio/server/events/filters.py +63 -0
- karrio/server/events/migrations/0001_initial.py +65 -0
- karrio/server/events/migrations/0002_event.py +39 -0
- karrio/server/events/migrations/0003_auto_20220303_1210.py +25 -0
- karrio/server/events/migrations/0004_custom_migration_2022_4.py +41 -0
- karrio/server/events/migrations/0005_event_event_object_idx.py +18 -0
- karrio/server/events/migrations/0006_webhook_events_alter_event_data.py +85 -0
- karrio/server/events/migrations/0007_auto_20221130_0255.py +30 -0
- karrio/server/events/migrations/0008_alter_event_type.py +18 -0
- karrio/server/events/migrations/0009_alter_webhook_enabled_events.py +45 -0
- karrio/server/events/migrations/__init__.py +0 -0
- karrio/server/events/models.py +83 -0
- karrio/server/events/router.py +3 -0
- karrio/server/events/serializers/__init__.py +1 -0
- karrio/server/events/serializers/base.py +63 -0
- karrio/server/events/serializers/event.py +9 -0
- karrio/server/events/serializers/webhook.py +27 -0
- karrio/server/events/signals.py +138 -0
- karrio/server/events/task_definitions/__init__.py +1 -0
- karrio/server/events/task_definitions/base/__init__.py +64 -0
- karrio/server/events/task_definitions/base/archiving.py +58 -0
- karrio/server/events/task_definitions/base/tracking.py +227 -0
- karrio/server/events/task_definitions/base/webhook.py +116 -0
- karrio/server/events/tasks.py +20 -0
- karrio/server/events/tests/__init__.py +7 -0
- karrio/server/events/tests/test_events.py +138 -0
- karrio/server/events/tests/test_tracking_tasks.py +345 -0
- karrio/server/events/tests/test_webhooks.py +132 -0
- karrio/server/events/tests.py +3 -0
- karrio/server/events/urls.py +10 -0
- karrio/server/events/views/__init__.py +2 -0
- karrio/server/events/views/webhooks.py +173 -0
- karrio/server/graph/schemas/__init__.py +1 -0
- karrio/server/graph/schemas/events/__init__.py +47 -0
- karrio/server/graph/schemas/events/inputs.py +43 -0
- karrio/server/graph/schemas/events/mutations.py +56 -0
- karrio/server/graph/schemas/events/types.py +79 -0
- karrio_server_events-2025.5rc1.dist-info/METADATA +28 -0
- karrio_server_events-2025.5rc1.dist-info/RECORD +44 -0
- karrio_server_events-2025.5rc1.dist-info/WHEEL +5 -0
- karrio_server_events-2025.5rc1.dist-info/top_level.txt +2 -0
@@ -0,0 +1,138 @@
|
|
1
|
+
import logging
|
2
|
+
from django.db.models import signals
|
3
|
+
|
4
|
+
from karrio.server.core import utils
|
5
|
+
from karrio.server.conf import settings
|
6
|
+
from karrio.server.events.serializers import EventTypes
|
7
|
+
import karrio.server.core.serializers as serializers
|
8
|
+
import karrio.server.manager.models as models
|
9
|
+
import karrio.server.events.tasks as tasks
|
10
|
+
|
11
|
+
logger = logging.getLogger(__name__)
|
12
|
+
|
13
|
+
|
14
|
+
def register_signals():
|
15
|
+
signals.post_save.connect(shipment_updated, sender=models.Shipment)
|
16
|
+
signals.post_delete.connect(shipment_cancelled, sender=models.Shipment)
|
17
|
+
signals.post_save.connect(tracker_updated, sender=models.Tracking)
|
18
|
+
|
19
|
+
logger.info("karrio.events signals registered...")
|
20
|
+
|
21
|
+
|
22
|
+
@utils.disable_for_loaddata
|
23
|
+
@utils.error_wrapper
|
24
|
+
def shipment_updated(
|
25
|
+
sender, instance, created, raw, using, update_fields, *args, **kwargs
|
26
|
+
):
|
27
|
+
"""Shipment related events:
|
28
|
+
- shipment purchased (label purchased)
|
29
|
+
- shipment fulfilled (shipped)
|
30
|
+
"""
|
31
|
+
is_bound = "created_at" in (update_fields or [])
|
32
|
+
status_updated = "status" in (update_fields or [])
|
33
|
+
|
34
|
+
if created:
|
35
|
+
return
|
36
|
+
if is_bound and instance.status == serializers.ShipmentStatus.purchased.value:
|
37
|
+
event = EventTypes.shipment_purchased.value
|
38
|
+
elif (
|
39
|
+
status_updated and instance.status == serializers.ShipmentStatus.purchased.value
|
40
|
+
):
|
41
|
+
event = EventTypes.shipment_purchased.value
|
42
|
+
elif (
|
43
|
+
status_updated
|
44
|
+
and instance.status == serializers.ShipmentStatus.in_transit.value
|
45
|
+
):
|
46
|
+
event = EventTypes.shipment_fulfilled.value
|
47
|
+
elif (
|
48
|
+
status_updated and instance.status == serializers.ShipmentStatus.cancelled.value
|
49
|
+
):
|
50
|
+
event = EventTypes.shipment_cancelled.value
|
51
|
+
elif (
|
52
|
+
status_updated
|
53
|
+
and instance.status == serializers.ShipmentStatus.out_for_delivery.value
|
54
|
+
):
|
55
|
+
event = EventTypes.shipment_out_for_delivery.value
|
56
|
+
elif (
|
57
|
+
status_updated
|
58
|
+
and instance.status == serializers.ShipmentStatus.needs_attention.value
|
59
|
+
):
|
60
|
+
event = EventTypes.shipment_needs_attention.value
|
61
|
+
elif (
|
62
|
+
status_updated
|
63
|
+
and instance.status == serializers.ShipmentStatus.delivery_failed.value
|
64
|
+
):
|
65
|
+
event = EventTypes.shipment_delivery_failed.value
|
66
|
+
else:
|
67
|
+
return
|
68
|
+
|
69
|
+
data = serializers.Shipment(instance).data
|
70
|
+
event_at = instance.updated_at
|
71
|
+
context = dict(
|
72
|
+
test_mode=instance.test_mode,
|
73
|
+
user_id=utils.failsafe(lambda: instance.created_by.id),
|
74
|
+
org_id=utils.failsafe(
|
75
|
+
lambda: instance.org.first().id if hasattr(instance, "org") else None
|
76
|
+
),
|
77
|
+
)
|
78
|
+
|
79
|
+
if settings.MULTI_ORGANIZATIONS and context["org_id"] is None:
|
80
|
+
return
|
81
|
+
|
82
|
+
tasks.notify_webhooks(event, data, event_at, context, schema=settings.schema)
|
83
|
+
|
84
|
+
|
85
|
+
@utils.disable_for_loaddata
|
86
|
+
def shipment_cancelled(sender, instance, *args, **kwargs):
|
87
|
+
"""Shipment related events:
|
88
|
+
- shipment cancelled/deleted (label voided)
|
89
|
+
"""
|
90
|
+
event = EventTypes.shipment_cancelled.value
|
91
|
+
data = serializers.Shipment(instance)
|
92
|
+
event_at = instance.updated_at
|
93
|
+
context = dict(
|
94
|
+
user_id=utils.failsafe(lambda: instance.created_by.id),
|
95
|
+
test_mode=instance.test_mode,
|
96
|
+
org_id=utils.failsafe(
|
97
|
+
lambda: instance.org.first().id if hasattr(instance, "org") else None
|
98
|
+
),
|
99
|
+
)
|
100
|
+
|
101
|
+
if settings.MULTI_ORGANIZATIONS and context["org_id"] is None:
|
102
|
+
return
|
103
|
+
|
104
|
+
tasks.notify_webhooks(event, data, event_at, context, schema=settings.schema)
|
105
|
+
|
106
|
+
|
107
|
+
@utils.disable_for_loaddata
|
108
|
+
def tracker_updated(
|
109
|
+
sender, instance, created, raw, using, update_fields, *args, **kwargs
|
110
|
+
):
|
111
|
+
"""Tracking related events:
|
112
|
+
- tracker created (pending)
|
113
|
+
- tracker status changed (in_transit, delivered or blocked)
|
114
|
+
"""
|
115
|
+
changes = update_fields or []
|
116
|
+
post_create = created or "created_at" in changes
|
117
|
+
|
118
|
+
if post_create:
|
119
|
+
event = EventTypes.tracker_created.value
|
120
|
+
elif any(field in changes for field in ["status", "events"]):
|
121
|
+
event = EventTypes.tracker_updated.value
|
122
|
+
else:
|
123
|
+
return
|
124
|
+
|
125
|
+
data = serializers.TrackingStatus(instance).data
|
126
|
+
event_at = instance.updated_at
|
127
|
+
context = dict(
|
128
|
+
user_id=utils.failsafe(lambda: instance.created_by.id),
|
129
|
+
test_mode=instance.test_mode,
|
130
|
+
org_id=utils.failsafe(
|
131
|
+
lambda: instance.org.first().id if hasattr(instance, "org") else None
|
132
|
+
),
|
133
|
+
)
|
134
|
+
|
135
|
+
if settings.MULTI_ORGANIZATIONS and context["org_id"] is None:
|
136
|
+
return
|
137
|
+
|
138
|
+
tasks.notify_webhooks(event, data, event_at, context, schema=settings.schema)
|
@@ -0,0 +1 @@
|
|
1
|
+
__path__ = __import__("pkgutil").extend_path(__path__, __name__) # type: ignore
|
@@ -0,0 +1,64 @@
|
|
1
|
+
import time
|
2
|
+
import logging
|
3
|
+
from django.conf import settings
|
4
|
+
from huey import crontab
|
5
|
+
from huey.contrib.djhuey import db_task, db_periodic_task
|
6
|
+
|
7
|
+
import karrio.server.core.utils as utils
|
8
|
+
|
9
|
+
logger = logging.getLogger(__name__)
|
10
|
+
DATA_ARCHIVING_SCHEDULE = int(getattr(settings, "DATA_ARCHIVING_SCHEDULE", 168))
|
11
|
+
DEFAULT_TRACKERS_UPDATE_INTERVAL = int(
|
12
|
+
getattr(settings, "DEFAULT_TRACKERS_UPDATE_INTERVAL", 7200) / 60
|
13
|
+
)
|
14
|
+
|
15
|
+
|
16
|
+
@db_periodic_task(crontab(minute=f"*/{DEFAULT_TRACKERS_UPDATE_INTERVAL}"))
|
17
|
+
def background_trackers_update():
|
18
|
+
from karrio.server.events.task_definitions.base import tracking
|
19
|
+
|
20
|
+
@utils.run_on_all_tenants
|
21
|
+
def _run(**kwargs):
|
22
|
+
try:
|
23
|
+
tracking.update_trackers()
|
24
|
+
except Exception as e:
|
25
|
+
logger.error(f"An error occured during tracking statuses update: {e}")
|
26
|
+
|
27
|
+
_run()
|
28
|
+
|
29
|
+
|
30
|
+
@db_task(retries=5, retry_delay=60)
|
31
|
+
@utils.tenant_aware
|
32
|
+
def notify_webhooks(*args, **kwargs):
|
33
|
+
try:
|
34
|
+
from karrio.server.events.task_definitions.base import webhook
|
35
|
+
|
36
|
+
webhook.notify_webhook_subscribers(*args, **kwargs)
|
37
|
+
|
38
|
+
except Exception as e:
|
39
|
+
logger.error(f"An error occured during webhook notification: {e}")
|
40
|
+
raise e
|
41
|
+
|
42
|
+
|
43
|
+
@db_periodic_task(crontab(hour=f"*/{DATA_ARCHIVING_SCHEDULE}"))
|
44
|
+
def periodic_data_archiving(*args, **kwargs):
|
45
|
+
from karrio.server.events.task_definitions.base import archiving
|
46
|
+
|
47
|
+
@utils.run_on_all_tenants
|
48
|
+
def _run(**kwargs):
|
49
|
+
try:
|
50
|
+
utils.failsafe(
|
51
|
+
lambda: archiving.run_data_archiving(*args, **kwargs),
|
52
|
+
"An error occured during data archiving: $error",
|
53
|
+
)
|
54
|
+
except Exception as e:
|
55
|
+
logger.error(f"An error occured during data archiving: {e}")
|
56
|
+
|
57
|
+
_run()
|
58
|
+
|
59
|
+
|
60
|
+
TASK_DEFINITIONS = [
|
61
|
+
background_trackers_update,
|
62
|
+
periodic_data_archiving,
|
63
|
+
notify_webhooks,
|
64
|
+
]
|
@@ -0,0 +1,58 @@
|
|
1
|
+
import logging
|
2
|
+
import datetime
|
3
|
+
import django.utils.timezone as timezone
|
4
|
+
|
5
|
+
import karrio.server.conf as conf
|
6
|
+
import karrio.server.core.utils as utils
|
7
|
+
import karrio.server.core.models as core
|
8
|
+
import karrio.server.events.models as events
|
9
|
+
import karrio.server.orders.models as orders
|
10
|
+
import karrio.server.tracing.models as tracing
|
11
|
+
import karrio.server.manager.models as manager
|
12
|
+
|
13
|
+
logger = logging.getLogger(__name__)
|
14
|
+
|
15
|
+
|
16
|
+
def run_data_archiving(*args, **kwargs):
|
17
|
+
now = timezone.now()
|
18
|
+
log_retention = now - datetime.timedelta(days=conf.settings.API_LOGS_DATA_RETENTION)
|
19
|
+
order_retention = now - datetime.timedelta(days=conf.settings.ORDER_DATA_RETENTION)
|
20
|
+
shipment_retention = now - datetime.timedelta(
|
21
|
+
days=conf.settings.SHIPMENT_DATA_RETENTION
|
22
|
+
)
|
23
|
+
tracker_retention = now - datetime.timedelta(
|
24
|
+
days=conf.settings.TRACKER_DATA_RETENTION
|
25
|
+
)
|
26
|
+
|
27
|
+
shipping_data = manager.Shipment.objects.filter(created_at__lt=shipment_retention)
|
28
|
+
tracking_Data = manager.Tracking.objects.filter(created_at__lt=tracker_retention)
|
29
|
+
tracing_data = tracing.TracingRecord.objects.filter(created_at__lt=log_retention)
|
30
|
+
api_log_data = core.APILog.objects.filter(requested_at__lt=log_retention)
|
31
|
+
order_data = orders.Order.objects.filter(created_at__lt=order_retention)
|
32
|
+
event_data = events.Event.objects.filter(created_at__lt=log_retention)
|
33
|
+
|
34
|
+
if any(tracing_data):
|
35
|
+
logger.info(">> archiving SDK tracing backlog...")
|
36
|
+
utils.failsafe(lambda: tracing_data.delete())
|
37
|
+
|
38
|
+
if any(event_data):
|
39
|
+
logger.info(">> archiving events backlog...")
|
40
|
+
utils.failsafe(lambda: event_data.delete())
|
41
|
+
|
42
|
+
if any(api_log_data):
|
43
|
+
logger.info(">> archiving API request logs backlog...")
|
44
|
+
utils.failsafe(lambda: api_log_data.delete())
|
45
|
+
|
46
|
+
if any(tracking_Data):
|
47
|
+
logger.info(">> archiving tracking data backlog...")
|
48
|
+
utils.failsafe(lambda: tracking_Data.delete())
|
49
|
+
|
50
|
+
if any(shipping_data):
|
51
|
+
logger.info(">> archiving shipping data backlog...")
|
52
|
+
utils.failsafe(lambda: shipping_data.delete())
|
53
|
+
|
54
|
+
if any(order_data):
|
55
|
+
logger.info(">> archiving order data backlog...")
|
56
|
+
utils.failsafe(lambda: order_data.delete())
|
57
|
+
|
58
|
+
logger.info("> ending scheduled backlog archiving!")
|
@@ -0,0 +1,227 @@
|
|
1
|
+
import time
|
2
|
+
import typing
|
3
|
+
import logging
|
4
|
+
import datetime
|
5
|
+
import functools
|
6
|
+
from itertools import groupby
|
7
|
+
|
8
|
+
from django.conf import settings
|
9
|
+
from django.utils import timezone
|
10
|
+
|
11
|
+
import karrio.sdk as karrio
|
12
|
+
import karrio.lib as lib
|
13
|
+
from karrio.api.gateway import Gateway
|
14
|
+
from karrio.api.interface import IRequestFrom
|
15
|
+
from karrio.core.models import TrackingDetails, Message, TrackingEvent
|
16
|
+
|
17
|
+
import karrio.server.core.utils as utils
|
18
|
+
import karrio.server.manager.models as models
|
19
|
+
import karrio.server.tracing.utils as tracing
|
20
|
+
import karrio.server.core.datatypes as datatypes
|
21
|
+
import karrio.server.manager.serializers as serializers
|
22
|
+
|
23
|
+
logger = logging.getLogger(__name__)
|
24
|
+
Delay = int
|
25
|
+
RequestBatches = typing.Tuple[
|
26
|
+
Gateway, IRequestFrom, Delay, typing.List[models.Tracking]
|
27
|
+
]
|
28
|
+
BatchResponse = typing.List[typing.Tuple[TrackingDetails, typing.List[Message]]]
|
29
|
+
|
30
|
+
DEFAULT_TRACKERS_UPDATE_INTERVAL = getattr(
|
31
|
+
settings, "DEFAULT_TRACKERS_UPDATE_INTERVAL", 7200
|
32
|
+
)
|
33
|
+
|
34
|
+
|
35
|
+
def update_trackers(
|
36
|
+
delta: datetime.timedelta = datetime.timedelta(
|
37
|
+
seconds=DEFAULT_TRACKERS_UPDATE_INTERVAL
|
38
|
+
),
|
39
|
+
tracker_ids: typing.List[str] = [],
|
40
|
+
):
|
41
|
+
logger.info("> starting scheduled trackers update")
|
42
|
+
|
43
|
+
active_trackers = lib.identity(
|
44
|
+
models.Tracking.objects.filter(id__in=tracker_ids)
|
45
|
+
if any(tracker_ids)
|
46
|
+
else models.Tracking.objects.filter(
|
47
|
+
delivered=False,
|
48
|
+
updated_at__lt=timezone.now() - delta,
|
49
|
+
)
|
50
|
+
)
|
51
|
+
|
52
|
+
if any(active_trackers):
|
53
|
+
trackers_grouped_by_carrier = [
|
54
|
+
list(g) for _, g in groupby(active_trackers, key=lambda t: t.carrier_id)
|
55
|
+
]
|
56
|
+
request_batches: typing.List[RequestBatches] = sum(
|
57
|
+
[create_request_batches(group) for group in trackers_grouped_by_carrier], []
|
58
|
+
)
|
59
|
+
|
60
|
+
responses = lib.run_concurently(fetch_tracking_info, request_batches, 2)
|
61
|
+
save_tracing_records(request_batches)
|
62
|
+
save_updated_trackers(responses, active_trackers)
|
63
|
+
else:
|
64
|
+
logger.info("no active trackers found needing update")
|
65
|
+
|
66
|
+
logger.info("> ending scheduled trackers update")
|
67
|
+
|
68
|
+
|
69
|
+
def create_request_batches(
|
70
|
+
trackers: typing.List[models.Tracking],
|
71
|
+
) -> typing.List[RequestBatches]:
|
72
|
+
start = 0
|
73
|
+
end = 10
|
74
|
+
batches = []
|
75
|
+
|
76
|
+
while any(trackers[start:end]):
|
77
|
+
try:
|
78
|
+
# Add a request delay to avoid sending two request batches to a carrier at the same time
|
79
|
+
delay = int(((end / 10) * 10) - 10)
|
80
|
+
# Get the common tracking carrier
|
81
|
+
carrier = trackers[0].tracking_carrier
|
82
|
+
# Collect the 5 trackers between the start and end indexes
|
83
|
+
batch_trackers = trackers[start:end]
|
84
|
+
tracking_numbers = [t.tracking_number for t in batch_trackers]
|
85
|
+
options: dict = functools.reduce(
|
86
|
+
lambda acc, t: {**acc, **(t.options or {})}, batch_trackers, {}
|
87
|
+
)
|
88
|
+
|
89
|
+
logger.debug(f"prepare tracking request for {tracking_numbers}")
|
90
|
+
|
91
|
+
# Prepare and send tracking request(s) using the karrio interface.
|
92
|
+
request: IRequestFrom = karrio.Tracking.fetch(
|
93
|
+
datatypes.TrackingRequest(
|
94
|
+
tracking_numbers=tracking_numbers, options=options
|
95
|
+
)
|
96
|
+
)
|
97
|
+
gateway: Gateway = carrier.gateway
|
98
|
+
|
99
|
+
batches.append((gateway, request, delay, batch_trackers))
|
100
|
+
|
101
|
+
except Exception as request_error:
|
102
|
+
logger.warning(f"failed to prepare tracking batch ({start}, {end}) request")
|
103
|
+
logger.error(request_error, exc_info=True)
|
104
|
+
|
105
|
+
end += 10
|
106
|
+
start += 10
|
107
|
+
|
108
|
+
return batches
|
109
|
+
|
110
|
+
|
111
|
+
def fetch_tracking_info(request_batch: RequestBatches) -> BatchResponse:
|
112
|
+
gateway, request, delay, trackers = request_batch
|
113
|
+
logger.debug(f"fetching batch {[t.tracking_number for t in trackers]}")
|
114
|
+
time.sleep(delay) # apply delay before request
|
115
|
+
|
116
|
+
try:
|
117
|
+
return utils.identity(lambda: request.from_(gateway).parse())
|
118
|
+
except Exception as request_error:
|
119
|
+
logger.warning("batch request failed")
|
120
|
+
logger.error(request_error, exc_info=True)
|
121
|
+
|
122
|
+
return []
|
123
|
+
|
124
|
+
|
125
|
+
@utils.error_wrapper
|
126
|
+
def save_tracing_records(request_batches: typing.List[RequestBatches]):
|
127
|
+
logger.info("> saving tracing records...")
|
128
|
+
|
129
|
+
try:
|
130
|
+
for request_batch in request_batches:
|
131
|
+
gateway, _, __, trackers = request_batch
|
132
|
+
|
133
|
+
if not any(trackers):
|
134
|
+
continue
|
135
|
+
|
136
|
+
context = serializers.get_object_context(trackers[0])
|
137
|
+
tracing.bulk_save_tracing_records(gateway.tracer, context=context)
|
138
|
+
except Exception as error:
|
139
|
+
print(error)
|
140
|
+
logger.warning("Failed failed saving tracing record...")
|
141
|
+
logger.error(error, exc_info=True)
|
142
|
+
|
143
|
+
|
144
|
+
def save_updated_trackers(
|
145
|
+
responses: typing.List[BatchResponse], trackers: typing.List[models.Tracking]
|
146
|
+
):
|
147
|
+
logger.info("> saving updated trackers")
|
148
|
+
|
149
|
+
for tracking_details, _ in responses:
|
150
|
+
for details in tracking_details or []:
|
151
|
+
try:
|
152
|
+
logger.debug(f"update tracking info for {details.tracking_number}")
|
153
|
+
related_trackers = [
|
154
|
+
t for t in trackers if t.tracking_number == details.tracking_number
|
155
|
+
]
|
156
|
+
for tracker in related_trackers:
|
157
|
+
# update values only if changed; This is important for webhooks notification
|
158
|
+
changes = []
|
159
|
+
meta = details.meta or {}
|
160
|
+
status = utils.compute_tracking_status(details).value
|
161
|
+
events = utils.process_events(
|
162
|
+
response_events=details.events,
|
163
|
+
current_events=tracker.events,
|
164
|
+
)
|
165
|
+
options = {
|
166
|
+
**(tracker.options or {}),
|
167
|
+
tracker.tracking_number: details.meta,
|
168
|
+
}
|
169
|
+
info = lib.to_dict(details.info or {})
|
170
|
+
|
171
|
+
if events != tracker.events:
|
172
|
+
tracker.events = events
|
173
|
+
changes.append("events")
|
174
|
+
|
175
|
+
if options != tracker.options:
|
176
|
+
tracker.options = options
|
177
|
+
changes.append("options")
|
178
|
+
|
179
|
+
if details.meta != tracker.meta:
|
180
|
+
tracker.meta = meta
|
181
|
+
changes.append("meta")
|
182
|
+
|
183
|
+
if details.delivered != tracker.delivered:
|
184
|
+
tracker.delivered = details.delivered
|
185
|
+
changes.append("delivered")
|
186
|
+
|
187
|
+
if status != tracker.status:
|
188
|
+
tracker.status = status
|
189
|
+
changes.append("status")
|
190
|
+
|
191
|
+
if details.estimated_delivery != tracker.estimated_delivery:
|
192
|
+
tracker.estimated_delivery = details.estimated_delivery
|
193
|
+
changes.append("estimated_delivery")
|
194
|
+
|
195
|
+
if details.images is not None and (
|
196
|
+
details.images.delivery_image != tracker.delivery_image
|
197
|
+
or details.images.signature_image != tracker.signature_image
|
198
|
+
):
|
199
|
+
changes.append("delivery_image")
|
200
|
+
changes.append("signature_image")
|
201
|
+
tracker.delivery_image = (
|
202
|
+
details.images.delivery_image or tracker.delivery_image
|
203
|
+
)
|
204
|
+
tracker.signature_image = (
|
205
|
+
details.images.signature_image or tracker.signature_image
|
206
|
+
)
|
207
|
+
|
208
|
+
if any(info.keys()) and info != tracker.info:
|
209
|
+
tracker.info = serializers.process_dictionaries_mutations(
|
210
|
+
["info"], dict(info=info), tracker
|
211
|
+
)["info"]
|
212
|
+
changes.append("info")
|
213
|
+
|
214
|
+
if any(changes):
|
215
|
+
tracker.save(update_fields=changes)
|
216
|
+
serializers.update_shipment_tracker(tracker)
|
217
|
+
logger.debug(
|
218
|
+
f"tracking info {details.tracking_number} updated successfully"
|
219
|
+
)
|
220
|
+
else:
|
221
|
+
logger.debug(f"no changes detect")
|
222
|
+
|
223
|
+
except Exception as update_error:
|
224
|
+
logger.warning(
|
225
|
+
f"failed to update tracker with tracking number: {details.tracking_number}"
|
226
|
+
)
|
227
|
+
logger.error(update_error, exc_info=True)
|
@@ -0,0 +1,116 @@
|
|
1
|
+
import typing
|
2
|
+
import requests
|
3
|
+
import logging
|
4
|
+
from datetime import datetime
|
5
|
+
from django.conf import settings
|
6
|
+
from django.db.models import Q
|
7
|
+
from django.contrib.auth import get_user_model
|
8
|
+
|
9
|
+
from karrio.core import utils
|
10
|
+
from karrio.server.core.utils import identity
|
11
|
+
from karrio.server.serializers import Context
|
12
|
+
from karrio.server.events import models
|
13
|
+
import karrio.server.events.serializers.event as serializers
|
14
|
+
|
15
|
+
logger = logging.getLogger(__name__)
|
16
|
+
NotificationResponse = typing.Tuple[str, requests.Response]
|
17
|
+
User = get_user_model()
|
18
|
+
|
19
|
+
|
20
|
+
def notify_webhook_subscribers(
|
21
|
+
event: str,
|
22
|
+
data: dict,
|
23
|
+
event_at: datetime,
|
24
|
+
ctx: dict,
|
25
|
+
**kwargs,
|
26
|
+
):
|
27
|
+
logger.info(f"> starting {event} subscribers notification")
|
28
|
+
context = retrieve_context(ctx)
|
29
|
+
query = (
|
30
|
+
(Q(enabled_events__icontains=event) | Q(enabled_events__icontains="all")),
|
31
|
+
(Q(disabled__isnull=True) | Q(disabled=False)),
|
32
|
+
)
|
33
|
+
|
34
|
+
webhooks = models.Webhook.access_by(context).filter(*query)
|
35
|
+
serializers.EventSerializer.map(
|
36
|
+
data=dict(
|
37
|
+
type=event,
|
38
|
+
data=data,
|
39
|
+
test_mode=context.test_mode,
|
40
|
+
pending_webhooks=webhooks.count(),
|
41
|
+
),
|
42
|
+
context=context,
|
43
|
+
).save()
|
44
|
+
|
45
|
+
if any(webhooks):
|
46
|
+
payload = dict(event=event, data=data)
|
47
|
+
responses: typing.List[NotificationResponse] = notify_subscribers(
|
48
|
+
webhooks, payload
|
49
|
+
)
|
50
|
+
update_notified_webhooks(webhooks, responses, event_at)
|
51
|
+
else:
|
52
|
+
logger.info("no subscribers found")
|
53
|
+
|
54
|
+
logger.info(f"> ending {event} subscribers notification")
|
55
|
+
|
56
|
+
|
57
|
+
def notify_subscribers(webhooks: typing.List[models.Webhook], payload: dict):
|
58
|
+
def notify_subscriber(webhook: models.Webhook):
|
59
|
+
response = identity(
|
60
|
+
lambda: requests.post(
|
61
|
+
webhook.url,
|
62
|
+
json=payload,
|
63
|
+
headers={
|
64
|
+
"Content-type": "application/json",
|
65
|
+
"X-Event-Id": webhook.secret,
|
66
|
+
},
|
67
|
+
)
|
68
|
+
)
|
69
|
+
|
70
|
+
return webhook.id, response
|
71
|
+
|
72
|
+
return utils.exec_async(notify_subscriber, webhooks)
|
73
|
+
|
74
|
+
|
75
|
+
def update_notified_webhooks(
|
76
|
+
webhooks: typing.List[models.Webhook],
|
77
|
+
responses: typing.List[NotificationResponse],
|
78
|
+
event_at: datetime,
|
79
|
+
):
|
80
|
+
logger.info("> saving updated webhooks")
|
81
|
+
|
82
|
+
for webhook_id, response in responses:
|
83
|
+
try:
|
84
|
+
logger.debug(f"update webhook {webhook_id}")
|
85
|
+
|
86
|
+
webhook = next((w for w in webhooks if w.id == webhook_id))
|
87
|
+
if response.ok:
|
88
|
+
webhook.last_event_at = event_at
|
89
|
+
webhook.failure_streak_count = 0
|
90
|
+
else:
|
91
|
+
webhook.failure_streak_count += 1
|
92
|
+
# Disable the webhook if notification failed more than 5 times
|
93
|
+
webhook.disabled = webhook.failure_streak_count > 5
|
94
|
+
|
95
|
+
webhook.save()
|
96
|
+
|
97
|
+
logger.debug(f"webhook {webhook_id} updated successfully")
|
98
|
+
|
99
|
+
except Exception as update_error:
|
100
|
+
logger.warning(f"failed to update webhook {webhook_id}")
|
101
|
+
logger.error(update_error, exc_info=True)
|
102
|
+
|
103
|
+
|
104
|
+
def retrieve_context(info: dict) -> Context:
|
105
|
+
org = None
|
106
|
+
|
107
|
+
if settings.MULTI_ORGANIZATIONS and "org_id" in info:
|
108
|
+
import karrio.server.orgs.models as orgs_models
|
109
|
+
|
110
|
+
org = orgs_models.Organization.objects.filter(id=info["org_id"]).first()
|
111
|
+
|
112
|
+
return Context(
|
113
|
+
org=org,
|
114
|
+
user=User.objects.filter(id=info["user_id"]).first(),
|
115
|
+
test_mode=info.get("test_mode"),
|
116
|
+
)
|
@@ -0,0 +1,20 @@
|
|
1
|
+
import typing
|
2
|
+
import logging
|
3
|
+
import pkgutil
|
4
|
+
import karrio.server.events.task_definitions as definitions
|
5
|
+
|
6
|
+
logger = logging.getLogger(__name__)
|
7
|
+
DEFINITIONS: typing.List[typing.Any] = []
|
8
|
+
|
9
|
+
# Register karrio background tasks
|
10
|
+
for _, name, _ in pkgutil.iter_modules(definitions.__path__): # type: ignore
|
11
|
+
try:
|
12
|
+
definition = __import__(f"{definitions.__name__}.{name}", fromlist=[name]) # type: ignore
|
13
|
+
if hasattr(definition, "TASK_DEFINITIONS"):
|
14
|
+
DEFINITIONS += definition.TASK_DEFINITIONS
|
15
|
+
except Exception as e:
|
16
|
+
logger.warning(f'Failed to register "{name}" schema')
|
17
|
+
logger.exception(e)
|
18
|
+
|
19
|
+
for wrapper in DEFINITIONS:
|
20
|
+
globals()[wrapper.task_class.__name__] = wrapper
|