diff --git a/_deploy/uwsgi.ini b/_deploy/uwsgi.ini index 9c62739..c0192e9 100644 --- a/_deploy/uwsgi.ini +++ b/_deploy/uwsgi.ini @@ -25,5 +25,7 @@ chmod-socket = 664 # clear environment on exit vacuum = true +smart-attach-daemon = /tmp/celery-main.pid /var/www/phzhik-poizonstore/run_celery.sh + env = LANG=C.UTF-8 enable-threads = true \ No newline at end of file diff --git a/external_api/cdek.py b/external_api/cdek.py index e7a2b5c..6ef4eef 100644 --- a/external_api/cdek.py +++ b/external_api/cdek.py @@ -1,5 +1,6 @@ import http import os +from contextlib import suppress from time import sleep from typing import Optional from urllib.parse import urljoin @@ -13,6 +14,71 @@ from store.utils import is_migration_running os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'poizonstore.settings') +class CDEKStatus: + # Принят + ACCEPTED = "ACCEPTED" + # Создан + CREATED = "CREATED" + # Принят на склад отправителя + RECEIVED_AT_SHIPMENT_WAREHOUSE = "RECEIVED_AT_SHIPMENT_WAREHOUSE" + # Выдан на отправку в г. отправителе + READY_FOR_SHIPMENT_IN_SENDER_CITY = "READY_FOR_SHIPMENT_IN_SENDER_CITY" + # Возвращен на склад отправителя + RETURNED_TO_SENDER_CITY_WAREHOUSE = "RETURNED_TO_SENDER_CITY_WAREHOUSE" + # Сдан перевозчику в г. отправителе + TAKEN_BY_TRANSPORTER_FROM_SENDER_CITY = "TAKEN_BY_TRANSPORTER_FROM_SENDER_CITY" + # Отправлен в г. транзит + SENT_TO_TRANSIT_CITY = "SENT_TO_TRANSIT_CITY" + # Встречен в г. транзите + ACCEPTED_IN_TRANSIT_CITY = "ACCEPTED_IN_TRANSIT_CITY" + # Принят на склад транзита + ACCEPTED_AT_TRANSIT_WAREHOUSE = "ACCEPTED_AT_TRANSIT_WAREHOUSE" + # Возвращен на склад транзита + RETURNED_TO_TRANSIT_WAREHOUSE = "RETURNED_TO_TRANSIT_WAREHOUSE" + # Выдан на отправку в г. транзите + READY_FOR_SHIPMENT_IN_TRANSIT_CITY = "READY_FOR_SHIPMENT_IN_TRANSIT_CITY" + # Сдан перевозчику в г. транзите + TAKEN_BY_TRANSPORTER_FROM_TRANSIT_CITY = "TAKEN_BY_TRANSPORTER_FROM_TRANSIT_CITY" + # Отправлен в г. отправитель + SENT_TO_SENDER_CITY = "SENT_TO_SENDER_CITY" + # Отправлен в г. получатель + SENT_TO_RECIPIENT_CITY = "SENT_TO_RECIPIENT_CITY" + # Встречен в г. отправителе + ACCEPTED_IN_SENDER_CITY = "ACCEPTED_IN_SENDER_CITY" + # Встречен в г. получателе + ACCEPTED_IN_RECIPIENT_CITY = "ACCEPTED_IN_RECIPIENT_CITY" + # Принят на склад доставки + ACCEPTED_AT_RECIPIENT_CITY_WAREHOUSE = "ACCEPTED_AT_RECIPIENT_CITY_WAREHOUSE" + # Принят на склад до востребования + ACCEPTED_AT_PICK_UP_POINT = "ACCEPTED_AT_PICK_UP_POINT" + # Выдан на доставку + TAKEN_BY_COURIER = "TAKEN_BY_COURIER" + # Возвращен на склад доставки + RETURNED_TO_RECIPIENT_CITY_WAREHOUSE = "RETURNED_TO_RECIPIENT_CITY_WAREHOUSE" + # Вручен + DELIVERED = "DELIVERED" + # Не вручен + NOT_DELIVERED = "NOT_DELIVERED" + # Некорректный заказ + INVALID = "INVALID" + # Таможенное оформление в стране отправления + IN_CUSTOMS_INTERNATIONAL = "IN_CUSTOMS_INTERNATIONAL" + # Отправлено в страну назначения + SHIPPED_TO_DESTINATION = "SHIPPED_TO_DESTINATION" + # Передано транзитному перевозчику + PASSED_TO_TRANSIT_CARRIER = "PASSED_TO_TRANSIT_CARRIER" + # Таможенное оформление в стране назначения + IN_CUSTOMS_LOCAL = "IN_CUSTOMS_LOCAL" + # Таможенное оформление завершено + CUSTOMS_COMPLETE = "CUSTOMS_COMPLETE" + # Заложен в постамат + POSTOMAT_POSTED = "POSTOMAT_POSTED" + # Изъят из постамата курьером + POSTOMAT_SEIZED = "POSTOMAT_SEIZED" + # Изъят из постамата клиентом + POSTOMAT_RECEIVED = "POSTOMAT_RECEIVED" + + class CDEKClient: AUTH_ENDPOINT = 'oauth/token' ORDER_INFO_ENDPOINT = 'orders' @@ -118,6 +184,22 @@ class CDEKClient: r = self.request('GET', url) return ContentFile(r.content) if r and r.content else None + def get_order_statuses(self, cdek_number): + params = { + 'cdek_number': str(cdek_number) + } + + r = self.request('GET', self.ORDER_INFO_ENDPOINT, params=params) + if not r: + return [] + + with suppress(KeyError): + statuses = r.json()['entity']['statuses'] + statuses = [s.get('code') for s in statuses] + return statuses + + return [] + client = CDEKClient(settings.CDEK_CLIENT_ID, settings.CDEK_CLIENT_SECRET) diff --git a/poizonstore/__init__.py b/poizonstore/__init__.py index e69de29..fb989c4 100644 --- a/poizonstore/__init__.py +++ b/poizonstore/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/poizonstore/celery.py b/poizonstore/celery.py new file mode 100644 index 0000000..ef8dec7 --- /dev/null +++ b/poizonstore/celery.py @@ -0,0 +1,22 @@ +import os +from datetime import timedelta + +from celery import Celery + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'poizonstore.settings') + +app = Celery('poizonstore') +app.config_from_object('django.conf:settings', namespace='CELERY') +app.autodiscover_tasks() + +app.conf.beat_schedule = { + 'update-cdek-status-every-hour': { + 'task': 'store.tasks.schedule_cdek_status_update', + 'schedule': timedelta(hours=1), + }, +} + + +@app.task() +def debug_task(): + print(f'Task complete') diff --git a/poizonstore/settings.py b/poizonstore/settings.py index 061a0e9..773bfb9 100644 --- a/poizonstore/settings.py +++ b/poizonstore/settings.py @@ -215,3 +215,12 @@ if not DEBUG: # We recommend adjusting this value in production. profiles_sample_rate=1.0, ) + +# Celery +BROKER_URL = 'redis://localhost:6379/1' +CELERY_RESULT_BACKEND = BROKER_URL +CELERY_BROKER_URL = BROKER_URL +CELERY_ACCEPT_CONTENT = ['application/json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_TIMEZONE = TIME_ZONE diff --git a/requirements.txt b/requirements.txt index 5d085d9..f8c6ae0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,11 @@ djoser==2.2.0 drf-extra-fields==3.5.0 Pillow==9.5.0 +# Tasks +celery==5.3.6 +redis==5.0.1 +flower==2.0.1 + # Misc tqdm==4.65.0 django-debug-toolbar==4.1.0 diff --git a/run_celery.sh b/run_celery.sh new file mode 100755 index 0000000..e79d3c4 --- /dev/null +++ b/run_celery.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +PROJECT_NAME="poizonstore" + +# Run Celery worker +echo 'Starting Celery worker' +celery -A $PROJECT_NAME worker -l INFO --pidfile=/tmp/celery.pid & + +# Wait for worker to start +until timeout -t 10 celery -A project inspect ping; do + >&2 echo "Celery workers not available" +done + +# Run flower for Celery management +echo 'Starting Celery flower' +celery -A $PROJECT_NAME flower --pidfile=/tmp/celery-flower.pid & + +# Run celery beat for periodic tasks +echo 'Starting Celery beat' +celery -A $PROJECT_NAME beat -l INFO --pidfile=/tmp/celery-beat.pid & diff --git a/stop_celery.sh b/stop_celery.sh new file mode 100755 index 0000000..c5d08bf --- /dev/null +++ b/stop_celery.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +killall celery \ No newline at end of file diff --git a/store/models.py b/store/models.py index 7df56c9..12722d9 100644 --- a/store/models.py +++ b/store/models.py @@ -355,6 +355,7 @@ class Checklist(models.Model): COMPLETED = "completed" PDF_AVAILABLE_STATUSES = (RUSSIA, SPLIT_WAITING, SPLIT_PAID, CDEK, COMPLETED) + CDEK_READY_STATUSES = (RUSSIA, SPLIT_PAID, CDEK) CHOICES = ( (DRAFT, 'Черновик'), diff --git a/store/tasks.py b/store/tasks.py new file mode 100644 index 0000000..1a0a20c --- /dev/null +++ b/store/tasks.py @@ -0,0 +1,46 @@ +from celery import shared_task +from django.db.models import Q + +from external_api.cdek import client as cdek_client, CDEKStatus +from .models import Checklist + + +@shared_task +def check_cdek_status(order_id): + obj = Checklist.objects.filter(id=order_id).first() + if obj is None or obj.cdek_tracking is None or obj.status == Checklist.Status.COMPLETED: + return + + # Get CDEK statuses + statuses = cdek_client.get_order_statuses(obj.cdek_tracking) + if not statuses: + return + + new_status = obj.status + if CDEKStatus.DELIVERED in statuses: + new_status = Checklist.Status.COMPLETED + elif CDEKStatus.READY_FOR_SHIPMENT_IN_SENDER_CITY in statuses: + new_status = Checklist.Status.CDEK + + # Update status + if obj.status != new_status: + print(f'Order [{obj.id}] status: {obj.status} -> {new_status}') + obj.status = new_status + obj.save() + return new_status + + +@shared_task +def schedule_cdek_status_update(): + qs = Checklist.objects.filter( + Q(cdek_tracking__isnull=False) & Q(status__in=Checklist.Status.CDEK_READY_STATUSES) + ) + + order_count = len(qs) + print(f'Scheduled to update {order_count} orders') + + # Spawn a sub-task for every order + for obj in qs: + check_cdek_status.delay(order_id=obj.id) + + return order_count