399 lines
13 KiB
Python
399 lines
13 KiB
Python
from datetime import timedelta
|
||
|
||
from django.contrib import admin, messages
|
||
from django.utils import timezone
|
||
from django.utils.decorators import method_decorator
|
||
from django.views.decorators.debug import sensitive_post_parameters
|
||
|
||
from .forms import IntegrationConnectionAdminForm
|
||
from .integrations.moysklad import MoySkladError
|
||
from .integrations.wildberries import WildberriesError
|
||
from .models import (
|
||
Cost,
|
||
ExternalProject,
|
||
IntegrationConnection,
|
||
IntegrationProvider,
|
||
IntegrationStatus,
|
||
IntegrationSyncRun,
|
||
Product,
|
||
ProductBinding,
|
||
ProductImage,
|
||
ProjectPayment,
|
||
Sale,
|
||
SaleLine,
|
||
SourceRecord,
|
||
SourceSystem,
|
||
StockSnapshot,
|
||
)
|
||
from .services import test_moysklad_connection, test_wildberries_connection
|
||
from .services.credentials import CredentialError
|
||
from .tasks import enqueue_connection_sync
|
||
|
||
|
||
@admin.register(IntegrationConnection)
|
||
class IntegrationConnectionAdmin(admin.ModelAdmin):
|
||
form = IntegrationConnectionAdminForm
|
||
list_display = [
|
||
"provider",
|
||
"source",
|
||
"status",
|
||
"account_name",
|
||
"auto_sync",
|
||
"last_sync_finished_at",
|
||
"next_sync_at",
|
||
]
|
||
list_filter = ["provider", "status"]
|
||
search_fields = ["source__name", "account_name"]
|
||
readonly_fields = [
|
||
"source",
|
||
"status",
|
||
"account_name",
|
||
"last_error",
|
||
"last_tested_at",
|
||
"last_sync_started_at",
|
||
"last_sync_finished_at",
|
||
"last_stats",
|
||
"task_id",
|
||
"next_sync_at",
|
||
"created_by",
|
||
"created_at",
|
||
"updated_at",
|
||
"credentials_configured",
|
||
]
|
||
fieldsets = [
|
||
(
|
||
"Подключение",
|
||
{"fields": ["provider", "source", "auth_type", "credentials_configured"]},
|
||
),
|
||
("Реквизиты", {"fields": ["token", "login", "password"]}),
|
||
(
|
||
"Расписание",
|
||
{"fields": ["auto_sync", "sync_interval_minutes", "next_sync_at"]},
|
||
),
|
||
(
|
||
"Состояние",
|
||
{
|
||
"fields": [
|
||
"status",
|
||
"account_name",
|
||
"last_error",
|
||
"last_tested_at",
|
||
"last_sync_started_at",
|
||
"last_sync_finished_at",
|
||
"last_stats",
|
||
"task_id",
|
||
]
|
||
},
|
||
),
|
||
(
|
||
"История",
|
||
{
|
||
"fields": ["created_by", "created_at", "updated_at"],
|
||
"classes": ["collapse"],
|
||
},
|
||
),
|
||
]
|
||
actions = ["test_connections", "sync_connections"]
|
||
|
||
@admin.display(boolean=True, description="Реквизиты сохранены")
|
||
def credentials_configured(self, obj):
|
||
return bool(obj.encrypted_credentials)
|
||
|
||
@method_decorator(sensitive_post_parameters("token", "login", "password"))
|
||
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
||
return super().changeform_view(request, object_id, form_url, extra_context)
|
||
|
||
def save_model(self, request, obj, form, change):
|
||
if not change:
|
||
obj.source, _ = SourceSystem.objects.get_or_create(
|
||
code=obj.provider,
|
||
defaults={"name": IntegrationProvider(obj.provider).label},
|
||
)
|
||
obj.created_by = request.user
|
||
super().save_model(request, obj, form, change)
|
||
|
||
@admin.action(description="Проверить выбранные подключения", permissions=["change"])
|
||
def test_connections(self, request, queryset):
|
||
for connection in queryset:
|
||
if connection.status in {
|
||
IntegrationStatus.QUEUED,
|
||
IntegrationStatus.SYNCING,
|
||
}:
|
||
self.message_user(
|
||
request, "Дождитесь завершения синхронизации.", messages.WARNING
|
||
)
|
||
continue
|
||
test = {
|
||
"moysklad": test_moysklad_connection,
|
||
"wildberries": test_wildberries_connection,
|
||
}.get(connection.provider)
|
||
if test is None:
|
||
self.message_user(
|
||
request, "Этот сервис пока не поддерживается.", messages.ERROR
|
||
)
|
||
continue
|
||
try:
|
||
context = test(connection)
|
||
except (MoySkladError, WildberriesError, CredentialError) as exc:
|
||
connection.status = IntegrationStatus.ERROR
|
||
connection.last_error = str(exc)
|
||
connection.last_tested_at = timezone.now()
|
||
connection.save(
|
||
update_fields=[
|
||
"status",
|
||
"last_error",
|
||
"last_tested_at",
|
||
"updated_at",
|
||
]
|
||
)
|
||
self.message_user(
|
||
request,
|
||
f"{connection.get_provider_display()}: {exc}",
|
||
messages.ERROR,
|
||
)
|
||
continue
|
||
connection.status = IntegrationStatus.READY
|
||
connection.account_name = context["account_name"]
|
||
connection.last_error = ""
|
||
connection.last_tested_at = timezone.now()
|
||
connection.next_sync_at = (
|
||
timezone.now() + timedelta(minutes=connection.sync_interval_minutes)
|
||
if connection.auto_sync
|
||
else None
|
||
)
|
||
connection.source.metadata = {
|
||
**connection.source.metadata,
|
||
"account_id": context["account_id"],
|
||
}
|
||
if context.get("tin"):
|
||
connection.source.metadata["tin"] = context["tin"]
|
||
connection.source.save(update_fields=["metadata", "updated_at"])
|
||
connection.save(
|
||
update_fields=[
|
||
"status",
|
||
"account_name",
|
||
"last_error",
|
||
"last_tested_at",
|
||
"next_sync_at",
|
||
"updated_at",
|
||
]
|
||
)
|
||
self.message_user(
|
||
request, f"{connection.get_provider_display()}: подключение проверено."
|
||
)
|
||
|
||
@admin.action(
|
||
description="Синхронизировать выбранные подключения", permissions=["change"]
|
||
)
|
||
def sync_connections(self, request, queryset):
|
||
for connection in queryset:
|
||
if not connection.encrypted_credentials:
|
||
self.message_user(
|
||
request, "Сначала сохраните реквизиты подключения.", messages.ERROR
|
||
)
|
||
continue
|
||
try:
|
||
run = enqueue_connection_sync(connection)
|
||
except Exception:
|
||
# Публикация задачи уже сохраняет ошибку в подключении и журнале.
|
||
self.message_user(
|
||
request,
|
||
"Не удалось запустить синхронизацию. Проверьте историю запусков.",
|
||
messages.ERROR,
|
||
)
|
||
continue
|
||
if run is None:
|
||
self.message_user(
|
||
request, "Синхронизация уже выполняется.", messages.WARNING
|
||
)
|
||
else:
|
||
self.message_user(
|
||
request,
|
||
f"{connection.get_provider_display()}: синхронизация поставлена в очередь.",
|
||
)
|
||
|
||
|
||
class ImportedDataAdmin(admin.ModelAdmin):
|
||
def get_readonly_fields(self, request, obj=None):
|
||
return [field.name for field in self.model._meta.fields]
|
||
|
||
def has_add_permission(self, request):
|
||
return False
|
||
|
||
def has_change_permission(self, request, obj=None):
|
||
return False
|
||
|
||
def has_delete_permission(self, request, obj=None):
|
||
return False
|
||
|
||
|
||
@admin.register(IntegrationSyncRun)
|
||
class IntegrationSyncRunAdmin(ImportedDataAdmin):
|
||
list_display = [
|
||
"connection",
|
||
"status",
|
||
"stage",
|
||
"stage_processed",
|
||
"stage_total",
|
||
"last_progress_at",
|
||
"created_at",
|
||
"finished_at",
|
||
]
|
||
list_filter = ["status", "stage", "connection__provider"]
|
||
readonly_fields = [field.name for field in IntegrationSyncRun._meta.fields]
|
||
|
||
|
||
@admin.register(ExternalProject)
|
||
class ExternalProjectAdmin(ImportedDataAdmin):
|
||
list_display = ["name", "code", "source", "archived", "synced_at"]
|
||
list_filter = ["source", "archived"]
|
||
search_fields = ["name", "code", "external_id"]
|
||
|
||
|
||
@admin.register(ProjectPayment)
|
||
class ProjectPaymentAdmin(ImportedDataAdmin):
|
||
list_display = [
|
||
"number",
|
||
"document_type",
|
||
"project",
|
||
"amount",
|
||
"currency",
|
||
"occurred_at",
|
||
"applicable",
|
||
]
|
||
list_filter = [
|
||
"source",
|
||
"document_type",
|
||
"currency",
|
||
"applicable",
|
||
"missing_from_source",
|
||
]
|
||
search_fields = [
|
||
"number",
|
||
"external_id",
|
||
"project__name",
|
||
"payee",
|
||
"expense_category",
|
||
]
|
||
list_select_related = ["project", "source"]
|
||
|
||
|
||
class ProductBindingInline(admin.TabularInline):
|
||
model = ProductBinding
|
||
extra = 0
|
||
autocomplete_fields = ["source"]
|
||
|
||
|
||
class ProductImageInline(admin.TabularInline):
|
||
model = ProductImage
|
||
extra = 0
|
||
readonly_fields = ["file", "thumbnail", "source_updated_at"]
|
||
|
||
|
||
@admin.register(Product)
|
||
class ProductAdmin(admin.ModelAdmin):
|
||
list_display = ["sku", "name", "kind", "parent", "is_active", "updated_at"]
|
||
list_filter = ["kind", "is_active"]
|
||
search_fields = ["sku", "name", "barcode", "bindings__external_sku"]
|
||
autocomplete_fields = ["parent"]
|
||
inlines = [ProductBindingInline, ProductImageInline]
|
||
|
||
|
||
@admin.register(ProductImage)
|
||
class ProductImageAdmin(admin.ModelAdmin):
|
||
list_display = ["product", "source", "filename", "is_primary", "position"]
|
||
list_filter = ["source", "is_primary"]
|
||
search_fields = ["product__sku", "product__name", "filename", "external_id"]
|
||
autocomplete_fields = ["product", "source"]
|
||
|
||
|
||
@admin.register(SourceSystem)
|
||
class SourceSystemAdmin(admin.ModelAdmin):
|
||
list_display = ["code", "name", "is_active", "updated_at"]
|
||
list_filter = ["is_active"]
|
||
search_fields = ["code", "name"]
|
||
|
||
|
||
@admin.register(SourceRecord)
|
||
class SourceRecordAdmin(admin.ModelAdmin):
|
||
list_display = ["source", "entity_type", "external_id", "received_at"]
|
||
list_filter = ["source", "entity_type"]
|
||
search_fields = ["external_id"]
|
||
readonly_fields = ["payload_hash", "received_at", "created_at"]
|
||
autocomplete_fields = ["source"]
|
||
|
||
|
||
@admin.register(ProductBinding)
|
||
class ProductBindingAdmin(admin.ModelAdmin):
|
||
list_display = ["product", "source", "external_id", "external_sku", "is_primary"]
|
||
list_filter = ["source", "is_primary"]
|
||
search_fields = ["product__sku", "external_id", "external_sku"]
|
||
autocomplete_fields = ["product", "source"]
|
||
|
||
|
||
class SaleLineInline(admin.TabularInline):
|
||
model = SaleLine
|
||
extra = 0
|
||
autocomplete_fields = ["product"]
|
||
|
||
|
||
@admin.register(SaleLine)
|
||
class SaleLineAdmin(admin.ModelAdmin):
|
||
list_display = ["sale", "product", "external_sku", "quantity", "gross_amount"]
|
||
search_fields = [
|
||
"external_id",
|
||
"external_sku",
|
||
"name",
|
||
"sale__number",
|
||
"product__sku",
|
||
]
|
||
autocomplete_fields = ["sale", "product"]
|
||
|
||
|
||
@admin.register(Sale)
|
||
class SaleAdmin(admin.ModelAdmin):
|
||
list_display = [
|
||
"number",
|
||
"source",
|
||
"event_type",
|
||
"channel",
|
||
"status",
|
||
"counts_as_sale",
|
||
"occurred_at",
|
||
]
|
||
list_filter = ["source", "event_type", "channel", "counts_as_sale", "currency"]
|
||
search_fields = ["number", "external_id", "lines__external_sku"]
|
||
autocomplete_fields = ["source", "source_record"]
|
||
inlines = [SaleLineInline]
|
||
|
||
|
||
@admin.register(Cost)
|
||
class CostAdmin(admin.ModelAdmin):
|
||
list_display = [
|
||
"cost_type",
|
||
"product",
|
||
"source",
|
||
"channel",
|
||
"amount",
|
||
"currency",
|
||
"occurred_at",
|
||
]
|
||
list_filter = ["cost_type", "source", "channel", "currency"]
|
||
search_fields = ["external_id", "product__sku", "product__name"]
|
||
autocomplete_fields = ["source", "product", "sale_line"]
|
||
|
||
|
||
@admin.register(StockSnapshot)
|
||
class StockSnapshotAdmin(admin.ModelAdmin):
|
||
list_display = [
|
||
"product",
|
||
"source",
|
||
"warehouse_name",
|
||
"quantity",
|
||
"reserved",
|
||
"captured_at",
|
||
]
|
||
list_filter = ["source", "warehouse_name"]
|
||
search_fields = ["product__sku", "product__name", "warehouse_external_id"]
|
||
autocomplete_fields = ["product", "source"]
|