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"]