226 lines
8.0 KiB
Python
226 lines
8.0 KiB
Python
import logging
|
||
from datetime import timedelta
|
||
|
||
from celery import shared_task
|
||
from django.db import transaction
|
||
from django.db.models import Q
|
||
from django.utils import timezone
|
||
|
||
from commerce.integrations import sync_registered_source
|
||
from commerce.integrations.moysklad import MoySkladError
|
||
from commerce.models import IntegrationConnection, IntegrationStatus, IntegrationSyncRun
|
||
from commerce.realtime import send_connection_update
|
||
from commerce.services import sync_moysklad_connection
|
||
from commerce.services.credentials import CredentialError
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _next_sync_at(connection, *, now=None):
|
||
if not connection.auto_sync:
|
||
return None
|
||
return (now or timezone.now()) + timedelta(minutes=connection.sync_interval_minutes)
|
||
|
||
|
||
def enqueue_connection_sync(connection):
|
||
"""Create and publish one run while preventing overlapping scans."""
|
||
|
||
with transaction.atomic():
|
||
locked = IntegrationConnection.objects.select_for_update().get(pk=connection.pk)
|
||
if locked.status in {IntegrationStatus.QUEUED, IntegrationStatus.SYNCING}:
|
||
return None
|
||
run = IntegrationSyncRun.objects.create(connection=locked)
|
||
locked.status = IntegrationStatus.QUEUED
|
||
locked.task_id = ""
|
||
locked.last_error = ""
|
||
locked.next_sync_at = _next_sync_at(locked)
|
||
locked.save(
|
||
update_fields=[
|
||
"status",
|
||
"task_id",
|
||
"last_error",
|
||
"next_sync_at",
|
||
"updated_at",
|
||
]
|
||
)
|
||
send_connection_update(locked, run=run)
|
||
try:
|
||
async_result = sync_moysklad_run.delay(str(run.pk))
|
||
except Exception:
|
||
retry_at = timezone.now() + timedelta(minutes=5)
|
||
IntegrationSyncRun.objects.filter(pk=run.pk).update(
|
||
status="error",
|
||
error="Не удалось поставить синхронизацию в очередь.",
|
||
finished_at=timezone.now(),
|
||
)
|
||
IntegrationConnection.objects.filter(pk=connection.pk).update(
|
||
status=IntegrationStatus.ERROR,
|
||
last_error="Не удалось поставить синхронизацию в очередь.",
|
||
next_sync_at=retry_at,
|
||
)
|
||
connection.refresh_from_db()
|
||
run.refresh_from_db()
|
||
send_connection_update(connection, run=run)
|
||
raise
|
||
task_id = async_result.id or ""
|
||
IntegrationSyncRun.objects.filter(pk=run.pk).update(task_id=task_id)
|
||
IntegrationConnection.objects.filter(pk=connection.pk).update(task_id=task_id)
|
||
run.task_id = task_id
|
||
return run
|
||
|
||
|
||
@shared_task(name="commerce.sync_source")
|
||
def sync_source(source_code: str, entity_type: str):
|
||
results = sync_registered_source(source_code, entity_type=entity_type)
|
||
return {
|
||
"processed": len(results),
|
||
"created": sum(result.created for result in results),
|
||
"changed": sum(result.changed for result in results),
|
||
}
|
||
|
||
|
||
@shared_task(bind=True, name="commerce.sync_moysklad_run")
|
||
def sync_moysklad_run(self, run_id: str):
|
||
run = IntegrationSyncRun.objects.select_related("connection__source").get(pk=run_id)
|
||
connection = run.connection
|
||
started_at = timezone.now()
|
||
run.status = "running"
|
||
run.started_at = started_at
|
||
run.task_id = self.request.id or run.task_id
|
||
run.error = ""
|
||
run.save(update_fields=["status", "started_at", "task_id", "error"])
|
||
connection.status = IntegrationStatus.SYNCING
|
||
connection.last_sync_started_at = started_at
|
||
connection.last_error = ""
|
||
connection.save(
|
||
update_fields=["status", "last_sync_started_at", "last_error", "updated_at"]
|
||
)
|
||
send_connection_update(connection, run=run)
|
||
|
||
def report_progress(current_stats):
|
||
now = timezone.now()
|
||
IntegrationSyncRun.objects.filter(pk=run.pk).update(
|
||
processed=current_stats.processed,
|
||
created_products=current_stats.created_products,
|
||
matched_products=current_stats.matched_products,
|
||
stock_snapshots=current_stats.stock_snapshots,
|
||
images=current_stats.images,
|
||
image_errors=current_stats.image_errors,
|
||
sales=current_stats.sales,
|
||
)
|
||
IntegrationConnection.objects.filter(pk=connection.pk).update(updated_at=now)
|
||
run.refresh_from_db()
|
||
connection.refresh_from_db()
|
||
send_connection_update(connection, run=run)
|
||
|
||
try:
|
||
stats = sync_moysklad_connection(
|
||
connection,
|
||
progress_callback=report_progress,
|
||
).to_dict()
|
||
except Exception as exc:
|
||
logger.exception(
|
||
"MoySklad synchronization failed",
|
||
extra={
|
||
"event": "commerce.connection.sync_failed",
|
||
"run_id": run_id,
|
||
"connection_id": str(connection.pk),
|
||
"provider": connection.provider,
|
||
"error_type": type(exc).__name__,
|
||
},
|
||
)
|
||
if isinstance(exc, (MoySkladError, CredentialError)):
|
||
message = str(exc)
|
||
else:
|
||
message = "Внутренняя ошибка синхронизации."
|
||
finished_at = timezone.now()
|
||
run.status = "error"
|
||
run.error = message
|
||
run.finished_at = finished_at
|
||
run.save(update_fields=["status", "error", "finished_at"])
|
||
connection.status = IntegrationStatus.ERROR
|
||
connection.last_error = message
|
||
connection.last_sync_finished_at = finished_at
|
||
connection.next_sync_at = _next_sync_at(connection, now=finished_at)
|
||
connection.save(
|
||
update_fields=[
|
||
"status",
|
||
"last_error",
|
||
"last_sync_finished_at",
|
||
"next_sync_at",
|
||
"updated_at",
|
||
]
|
||
)
|
||
send_connection_update(connection, run=run)
|
||
raise
|
||
|
||
finished_at = timezone.now()
|
||
run.status = "success"
|
||
run.processed = stats["processed"]
|
||
run.created_products = stats["created_products"]
|
||
run.matched_products = stats["matched_products"]
|
||
run.stock_snapshots = stats["stock_snapshots"]
|
||
run.images = stats["images"]
|
||
run.image_errors = stats["image_errors"]
|
||
run.sales = stats["sales"]
|
||
run.finished_at = finished_at
|
||
run.save(
|
||
update_fields=[
|
||
"status",
|
||
"processed",
|
||
"created_products",
|
||
"matched_products",
|
||
"stock_snapshots",
|
||
"images",
|
||
"image_errors",
|
||
"sales",
|
||
"finished_at",
|
||
]
|
||
)
|
||
connection.status = IntegrationStatus.READY
|
||
connection.last_stats = stats
|
||
connection.last_error = ""
|
||
connection.last_sync_finished_at = finished_at
|
||
connection.next_sync_at = _next_sync_at(connection, now=finished_at)
|
||
connection.save(
|
||
update_fields=[
|
||
"status",
|
||
"last_stats",
|
||
"last_error",
|
||
"last_sync_finished_at",
|
||
"next_sync_at",
|
||
"updated_at",
|
||
]
|
||
)
|
||
send_connection_update(connection, run=run)
|
||
return stats
|
||
|
||
|
||
@shared_task(name="commerce.dispatch_due_connection_syncs")
|
||
def dispatch_due_connection_syncs():
|
||
now = timezone.now()
|
||
stale_cutoff = now - timedelta(minutes=45)
|
||
IntegrationConnection.objects.filter(
|
||
auto_sync=True,
|
||
status__in=[IntegrationStatus.QUEUED, IntegrationStatus.SYNCING],
|
||
updated_at__lte=stale_cutoff,
|
||
).update(
|
||
status=IntegrationStatus.ERROR,
|
||
last_error="Предыдущая синхронизация была прервана и будет перезапущена.",
|
||
next_sync_at=now,
|
||
updated_at=now,
|
||
)
|
||
due_ids = list(
|
||
IntegrationConnection.objects.filter(
|
||
auto_sync=True,
|
||
status__in=[IntegrationStatus.READY, IntegrationStatus.ERROR],
|
||
)
|
||
.filter(Q(next_sync_at__isnull=True) | Q(next_sync_at__lte=now))
|
||
.values_list("pk", flat=True)
|
||
)
|
||
queued = 0
|
||
for connection in IntegrationConnection.objects.filter(pk__in=due_ids):
|
||
if enqueue_connection_sync(connection) is not None:
|
||
queued += 1
|
||
return {"queued": queued}
|