Corp/backend/commerce/admin.py

300 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 .services import test_moysklad_connection, test_wildberries_connection
from .services.credentials import CredentialError
from .tasks import enqueue_connection_sync
from .models import (
Cost,
ExternalProject,
IntegrationConnection,
IntegrationProvider,
IntegrationStatus,
IntegrationSyncRun,
Product,
ProductBinding,
ProductImage,
ProjectPayment,
Sale,
SaleLine,
SourceRecord,
SourceSystem,
StockSnapshot,
)
@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"]