904 lines
28 KiB
Python
904 lines
28 KiB
Python
"""
|
||
Admin configuration for parsers app.
|
||
"""
|
||
|
||
import hashlib
|
||
import time
|
||
import uuid
|
||
from pathlib import Path
|
||
|
||
from apps.core.models import BackgroundJob
|
||
from apps.core.services import BackgroundJobService
|
||
from apps.parsers.models import (
|
||
FinancialReport,
|
||
FinancialReportLine,
|
||
IndustrialCertificateRecord,
|
||
IndustrialProductRecord,
|
||
InspectionRecord,
|
||
ManufacturerRecord,
|
||
ParserLoadLog,
|
||
ProcurementRecord,
|
||
Proxy,
|
||
)
|
||
from apps.parsers.serializers import FNSFileUploadSerializer
|
||
from apps.parsers.services import FNSReportService
|
||
from apps.parsers.tasks import process_fns_file
|
||
from django.conf import settings
|
||
from django.contrib import admin, messages
|
||
from django.shortcuts import redirect
|
||
from django.template.response import TemplateResponse
|
||
from django.urls import path, reverse
|
||
from django.utils.html import format_html
|
||
|
||
|
||
@admin.register(Proxy)
|
||
class ProxyAdmin(admin.ModelAdmin):
|
||
"""Admin для прокси-серверов."""
|
||
|
||
list_display = [
|
||
"address",
|
||
"is_active_badge",
|
||
"fail_count",
|
||
"last_used_at",
|
||
"created_at",
|
||
]
|
||
list_filter = ["is_active", "created_at"]
|
||
search_fields = ["address"]
|
||
readonly_fields = ["created_at", "updated_at", "last_used_at"]
|
||
ordering = ["-is_active", "-last_used_at"]
|
||
list_per_page = 50
|
||
|
||
fieldsets = (
|
||
("Основное", {"fields": ("address", "is_active")}),
|
||
("Статистика", {"fields": ("fail_count", "last_used_at")}),
|
||
("Даты", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
|
||
)
|
||
|
||
def is_active_badge(self, obj):
|
||
"""Цветной бейдж активности."""
|
||
if obj.is_active:
|
||
return format_html(
|
||
'<span style="color: white; background: #28a745; padding: 3px 10px; '
|
||
'border-radius: 3px;">Активен</span>'
|
||
)
|
||
return format_html(
|
||
'<span style="color: white; background: #dc3545; padding: 3px 10px; '
|
||
'border-radius: 3px;">Неактивен</span>'
|
||
)
|
||
|
||
is_active_badge.short_description = "Статус"
|
||
is_active_badge.admin_order_field = "is_active"
|
||
|
||
actions = ["activate_proxies", "deactivate_proxies", "reset_fail_count"]
|
||
|
||
@admin.action(description="Активировать выбранные прокси")
|
||
def activate_proxies(self, request, queryset):
|
||
updated = queryset.update(is_active=True)
|
||
self.message_user(request, f"Активировано {updated} прокси")
|
||
|
||
@admin.action(description="Деактивировать выбранные прокси")
|
||
def deactivate_proxies(self, request, queryset):
|
||
updated = queryset.update(is_active=False)
|
||
self.message_user(request, f"Деактивировано {updated} прокси")
|
||
|
||
@admin.action(description="Сбросить счётчик ошибок")
|
||
def reset_fail_count(self, request, queryset):
|
||
updated = queryset.update(fail_count=0)
|
||
self.message_user(request, f"Сброшен счётчик для {updated} прокси")
|
||
|
||
|
||
@admin.register(ParserLoadLog)
|
||
class ParserLoadLogAdmin(admin.ModelAdmin):
|
||
"""Admin для логов загрузки."""
|
||
|
||
list_display = [
|
||
"id",
|
||
"source",
|
||
"batch_id",
|
||
"status_badge",
|
||
"records_count",
|
||
"created_at",
|
||
]
|
||
list_filter = ["source", "status", "created_at"]
|
||
search_fields = ["batch_id", "error_message"]
|
||
readonly_fields = ["created_at", "updated_at"]
|
||
ordering = ["-created_at"]
|
||
list_per_page = 50
|
||
date_hierarchy = "created_at"
|
||
|
||
fieldsets = (
|
||
("Основное", {"fields": ("source", "batch_id", "status")}),
|
||
("Результат", {"fields": ("records_count", "error_message")}),
|
||
("Даты", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
|
||
)
|
||
|
||
def status_badge(self, obj):
|
||
"""Цветной бейдж статуса."""
|
||
colors = {
|
||
"success": "#28a745",
|
||
"failed": "#dc3545",
|
||
"in_progress": "#ffc107",
|
||
"pending": "#6c757d",
|
||
}
|
||
color = colors.get(obj.status, "#6c757d")
|
||
return format_html(
|
||
'<span style="color: white; background: {}; padding: 3px 10px; '
|
||
'border-radius: 3px;">{}</span>',
|
||
color,
|
||
obj.get_status_display()
|
||
if hasattr(obj, "get_status_display")
|
||
else obj.status,
|
||
)
|
||
|
||
status_badge.short_description = "Статус"
|
||
status_badge.admin_order_field = "status"
|
||
|
||
def has_add_permission(self, request):
|
||
"""Запретить создание логов вручную."""
|
||
return False
|
||
|
||
|
||
class HasCertificateNumberFilter(admin.SimpleListFilter):
|
||
"""Фильтр по наличию номера сертификата."""
|
||
|
||
title = "Номер сертификата"
|
||
parameter_name = "has_cert_number"
|
||
|
||
def lookups(self, request, model_admin):
|
||
return [
|
||
("yes", "С номером"),
|
||
("no", "Без номера"),
|
||
]
|
||
|
||
def queryset(self, request, queryset):
|
||
if self.value() == "yes":
|
||
return queryset.exclude(certificate_number__in=["-", ""])
|
||
if self.value() == "no":
|
||
return queryset.filter(certificate_number__in=["-", ""])
|
||
return queryset
|
||
|
||
|
||
@admin.register(IndustrialCertificateRecord)
|
||
class IndustrialCertificateRecordAdmin(admin.ModelAdmin):
|
||
"""Admin для сертификатов промышленного производства."""
|
||
|
||
list_display = [
|
||
"certificate_number",
|
||
"organisation_name_short",
|
||
"inn",
|
||
"ogrn",
|
||
"issue_date",
|
||
"expiry_date",
|
||
"load_batch",
|
||
]
|
||
list_filter = [HasCertificateNumberFilter, "load_batch", "created_at"]
|
||
search_fields = [
|
||
"certificate_number",
|
||
"organisation_name",
|
||
"inn",
|
||
"ogrn",
|
||
]
|
||
readonly_fields = ["created_at", "updated_at", "load_batch"]
|
||
ordering = ["-created_at"]
|
||
list_per_page = 100
|
||
date_hierarchy = "created_at"
|
||
raw_id_fields = []
|
||
|
||
fieldsets = (
|
||
(
|
||
"Сертификат",
|
||
{"fields": ("certificate_number", "issue_date", "expiry_date")},
|
||
),
|
||
(
|
||
"Организация",
|
||
{"fields": ("organisation_name", "inn", "ogrn")},
|
||
),
|
||
(
|
||
"Документ",
|
||
{"fields": ("certificate_file_url",), "classes": ("collapse",)},
|
||
),
|
||
(
|
||
"Системное",
|
||
{
|
||
"fields": ("load_batch", "created_at", "updated_at"),
|
||
"classes": ("collapse",),
|
||
},
|
||
),
|
||
)
|
||
|
||
def organisation_name_short(self, obj):
|
||
"""Сокращённое название организации."""
|
||
name = obj.organisation_name or ""
|
||
return name[:60] + "..." if len(name) > 60 else name
|
||
|
||
organisation_name_short.short_description = "Организация"
|
||
organisation_name_short.admin_order_field = "organisation_name"
|
||
|
||
def has_add_permission(self, request):
|
||
"""Запретить создание записей вручную."""
|
||
return False
|
||
|
||
def has_change_permission(self, request, obj=None):
|
||
"""Запретить редактирование записей."""
|
||
return False
|
||
|
||
|
||
@admin.register(ManufacturerRecord)
|
||
class ManufacturerRecordAdmin(admin.ModelAdmin):
|
||
"""Admin для реестра производителей."""
|
||
|
||
list_display = [
|
||
"full_legal_name_short",
|
||
"inn",
|
||
"ogrn",
|
||
"address_short",
|
||
"load_batch",
|
||
"created_at",
|
||
]
|
||
list_filter = ["load_batch", "created_at"]
|
||
search_fields = [
|
||
"full_legal_name",
|
||
"inn",
|
||
"ogrn",
|
||
"address",
|
||
]
|
||
readonly_fields = ["created_at", "updated_at", "load_batch"]
|
||
ordering = ["-created_at"]
|
||
list_per_page = 100
|
||
date_hierarchy = "created_at"
|
||
|
||
fieldsets = (
|
||
(
|
||
"Организация",
|
||
{"fields": ("full_legal_name", "inn", "ogrn")},
|
||
),
|
||
(
|
||
"Адрес",
|
||
{"fields": ("address",)},
|
||
),
|
||
(
|
||
"Системное",
|
||
{
|
||
"fields": ("load_batch", "created_at", "updated_at"),
|
||
"classes": ("collapse",),
|
||
},
|
||
),
|
||
)
|
||
|
||
def full_legal_name_short(self, obj):
|
||
"""Сокращённое название."""
|
||
name = obj.full_legal_name or ""
|
||
return name[:60] + "..." if len(name) > 60 else name
|
||
|
||
full_legal_name_short.short_description = "Название"
|
||
full_legal_name_short.admin_order_field = "full_legal_name"
|
||
|
||
def address_short(self, obj):
|
||
"""Сокращённый адрес."""
|
||
addr = obj.address or ""
|
||
return addr[:40] + "..." if len(addr) > 40 else addr
|
||
|
||
address_short.short_description = "Адрес"
|
||
address_short.admin_order_field = "address"
|
||
|
||
def has_add_permission(self, request):
|
||
"""Запретить создание записей вручную."""
|
||
return False
|
||
|
||
def has_change_permission(self, request, obj=None):
|
||
"""Запретить редактирование записей."""
|
||
return False
|
||
|
||
|
||
@admin.register(IndustrialProductRecord)
|
||
class IndustrialProductRecordAdmin(admin.ModelAdmin):
|
||
"""Admin для реестра промышленной продукции."""
|
||
|
||
list_display = [
|
||
"registry_number",
|
||
"product_name_short",
|
||
"organisation_name_short",
|
||
"inn",
|
||
"ogrn",
|
||
"load_batch",
|
||
"created_at",
|
||
]
|
||
list_filter = ["load_batch", "created_at"]
|
||
search_fields = [
|
||
"registry_number",
|
||
"product_name",
|
||
"full_organisation_name",
|
||
"inn",
|
||
"ogrn",
|
||
"okpd2_code",
|
||
"tnved_code",
|
||
]
|
||
readonly_fields = ["created_at", "updated_at", "load_batch"]
|
||
ordering = ["-created_at"]
|
||
list_per_page = 100
|
||
date_hierarchy = "created_at"
|
||
|
||
fieldsets = (
|
||
(
|
||
"Запись реестра",
|
||
{
|
||
"fields": (
|
||
"registry_number",
|
||
"product_name",
|
||
"product_model",
|
||
"regulatory_document",
|
||
)
|
||
},
|
||
),
|
||
(
|
||
"Организация",
|
||
{"fields": ("full_organisation_name", "inn", "ogrn")},
|
||
),
|
||
(
|
||
"Классификаторы",
|
||
{"fields": ("okpd2_code", "tnved_code")},
|
||
),
|
||
(
|
||
"Системное",
|
||
{
|
||
"fields": ("load_batch", "created_at", "updated_at"),
|
||
"classes": ("collapse",),
|
||
},
|
||
),
|
||
)
|
||
|
||
def product_name_short(self, obj):
|
||
"""Сокращённое название продукции."""
|
||
name = obj.product_name or ""
|
||
return name[:60] + "..." if len(name) > 60 else name
|
||
|
||
def organisation_name_short(self, obj):
|
||
"""Сокращённое название организации."""
|
||
name = obj.full_organisation_name or ""
|
||
return name[:60] + "..." if len(name) > 60 else name
|
||
|
||
product_name_short.short_description = "Продукция"
|
||
product_name_short.admin_order_field = "product_name"
|
||
organisation_name_short.short_description = "Организация"
|
||
organisation_name_short.admin_order_field = "full_organisation_name"
|
||
|
||
def has_add_permission(self, request):
|
||
"""Запретить создание записей вручную."""
|
||
return False
|
||
|
||
def has_change_permission(self, request, obj=None):
|
||
"""Запретить редактирование записей."""
|
||
return False
|
||
|
||
|
||
@admin.register(InspectionRecord)
|
||
class InspectionRecordAdmin(admin.ModelAdmin):
|
||
"""Admin для проверок из Единого реестра проверок."""
|
||
|
||
list_display = [
|
||
"registration_number",
|
||
"organisation_name_short",
|
||
"inn",
|
||
"control_authority_short",
|
||
"inspection_type",
|
||
"status_badge",
|
||
"start_date",
|
||
"load_batch",
|
||
]
|
||
list_filter = [
|
||
"inspection_type",
|
||
"inspection_form",
|
||
"status",
|
||
"load_batch",
|
||
"created_at",
|
||
]
|
||
search_fields = [
|
||
"registration_number",
|
||
"organisation_name",
|
||
"inn",
|
||
"ogrn",
|
||
"control_authority",
|
||
]
|
||
readonly_fields = ["created_at", "updated_at", "load_batch"]
|
||
ordering = ["-created_at"]
|
||
list_per_page = 100
|
||
date_hierarchy = "created_at"
|
||
|
||
fieldsets = (
|
||
(
|
||
"Проверка",
|
||
{
|
||
"fields": (
|
||
"registration_number",
|
||
"inspection_type",
|
||
"inspection_form",
|
||
"status",
|
||
)
|
||
},
|
||
),
|
||
(
|
||
"Организация",
|
||
{"fields": ("organisation_name", "inn", "ogrn")},
|
||
),
|
||
(
|
||
"Контрольный орган",
|
||
{"fields": ("control_authority", "legal_basis")},
|
||
),
|
||
(
|
||
"Сроки и результат",
|
||
{"fields": ("start_date", "end_date", "result")},
|
||
),
|
||
(
|
||
"Системное",
|
||
{
|
||
"fields": ("load_batch", "created_at", "updated_at"),
|
||
"classes": ("collapse",),
|
||
},
|
||
),
|
||
)
|
||
|
||
def organisation_name_short(self, obj):
|
||
"""Сокращённое название организации."""
|
||
name = obj.organisation_name or ""
|
||
return name[:50] + "..." if len(name) > 50 else name
|
||
|
||
organisation_name_short.short_description = "Организация"
|
||
organisation_name_short.admin_order_field = "organisation_name"
|
||
|
||
def control_authority_short(self, obj):
|
||
"""Сокращённое название контрольного органа."""
|
||
name = obj.control_authority or ""
|
||
return name[:30] + "..." if len(name) > 30 else name
|
||
|
||
control_authority_short.short_description = "Контр. орган"
|
||
control_authority_short.admin_order_field = "control_authority"
|
||
|
||
def status_badge(self, obj):
|
||
"""Цветной бейдж статуса."""
|
||
status = obj.status or ""
|
||
status_lower = status.lower()
|
||
|
||
if "завершен" in status_lower:
|
||
color = "#28a745"
|
||
elif "процесс" in status_lower or "проведен" in status_lower:
|
||
color = "#ffc107"
|
||
elif "отменен" in status_lower or "прекращ" in status_lower:
|
||
color = "#dc3545"
|
||
else:
|
||
color = "#6c757d"
|
||
|
||
return format_html(
|
||
'<span style="color: white; background: {}; padding: 2px 8px; '
|
||
'border-radius: 3px; font-size: 11px;">{}</span>',
|
||
color,
|
||
status[:20] if len(status) > 20 else status,
|
||
)
|
||
|
||
status_badge.short_description = "Статус"
|
||
status_badge.admin_order_field = "status"
|
||
|
||
def has_add_permission(self, request):
|
||
"""Запретить создание записей вручную."""
|
||
return False
|
||
|
||
def has_change_permission(self, request, obj=None):
|
||
"""Запретить редактирование записей."""
|
||
return False
|
||
|
||
|
||
@admin.register(ProcurementRecord)
|
||
class ProcurementRecordAdmin(admin.ModelAdmin):
|
||
"""Admin для государственных закупок."""
|
||
|
||
list_display = [
|
||
"purchase_number",
|
||
"purchase_name_short",
|
||
"customer_inn",
|
||
"customer_name_short",
|
||
"max_price",
|
||
"law_type",
|
||
"status_badge",
|
||
"publish_date",
|
||
"load_batch",
|
||
]
|
||
list_filter = [
|
||
"law_type",
|
||
"status",
|
||
"region_code",
|
||
"load_batch",
|
||
"created_at",
|
||
]
|
||
search_fields = [
|
||
"purchase_number",
|
||
"purchase_name",
|
||
"customer_inn",
|
||
"customer_ogrn",
|
||
"customer_name",
|
||
]
|
||
readonly_fields = ["created_at", "updated_at", "load_batch"]
|
||
ordering = ["-created_at"]
|
||
list_per_page = 100
|
||
date_hierarchy = "created_at"
|
||
|
||
fieldsets = (
|
||
(
|
||
"Закупка",
|
||
{
|
||
"fields": (
|
||
"purchase_number",
|
||
"purchase_name",
|
||
"purchase_object_info",
|
||
"law_type",
|
||
"status",
|
||
)
|
||
},
|
||
),
|
||
(
|
||
"Заказчик",
|
||
{
|
||
"fields": (
|
||
"customer_name",
|
||
"customer_inn",
|
||
"customer_kpp",
|
||
"customer_ogrn",
|
||
)
|
||
},
|
||
),
|
||
(
|
||
"Финансы",
|
||
{"fields": ("max_price", "currency_code", "placement_method")},
|
||
),
|
||
(
|
||
"Сроки",
|
||
{"fields": ("publish_date", "end_date")},
|
||
),
|
||
(
|
||
"Дополнительно",
|
||
{"fields": ("region_code", "href"), "classes": ("collapse",)},
|
||
),
|
||
(
|
||
"Системное",
|
||
{
|
||
"fields": (
|
||
"load_batch",
|
||
"data_year",
|
||
"data_month",
|
||
"created_at",
|
||
"updated_at",
|
||
),
|
||
"classes": ("collapse",),
|
||
},
|
||
),
|
||
)
|
||
|
||
def purchase_name_short(self, obj):
|
||
"""Сокращённое наименование закупки."""
|
||
name = obj.purchase_name or ""
|
||
return name[:50] + "..." if len(name) > 50 else name
|
||
|
||
purchase_name_short.short_description = "Наименование"
|
||
purchase_name_short.admin_order_field = "purchase_name"
|
||
|
||
def customer_name_short(self, obj):
|
||
"""Сокращённое наименование заказчика."""
|
||
name = obj.customer_name or ""
|
||
return name[:30] + "..." if len(name) > 30 else name
|
||
|
||
customer_name_short.short_description = "Заказчик"
|
||
customer_name_short.admin_order_field = "customer_name"
|
||
|
||
def status_badge(self, obj):
|
||
"""Цветной бейдж статуса."""
|
||
status = obj.status or ""
|
||
status_lower = status.lower()
|
||
|
||
if "опублик" in status_lower or "подача" in status_lower:
|
||
color = "#28a745"
|
||
elif "завершен" in status_lower or "состоял" in status_lower:
|
||
color = "#17a2b8"
|
||
elif "отменен" in status_lower or "не состоял" in status_lower:
|
||
color = "#dc3545"
|
||
else:
|
||
color = "#6c757d"
|
||
|
||
return format_html(
|
||
'<span style="color: white; background: {}; padding: 2px 8px; '
|
||
'border-radius: 3px; font-size: 11px;">{}</span>',
|
||
color,
|
||
status[:20] if len(status) > 20 else status,
|
||
)
|
||
|
||
status_badge.short_description = "Статус"
|
||
status_badge.admin_order_field = "status"
|
||
|
||
def has_add_permission(self, request):
|
||
"""Запретить создание записей вручную."""
|
||
return False
|
||
|
||
def has_change_permission(self, request, obj=None):
|
||
"""Запретить редактирование записей."""
|
||
return False
|
||
|
||
|
||
class FinancialReportLineInline(admin.TabularInline):
|
||
"""Inline для строк финансового отчета."""
|
||
|
||
model = FinancialReportLine
|
||
extra = 0
|
||
readonly_fields = [
|
||
"form_code",
|
||
"line_code",
|
||
"line_name",
|
||
"year",
|
||
"period_start",
|
||
"period_end",
|
||
]
|
||
can_delete = False
|
||
|
||
def has_add_permission(self, request, obj=None):
|
||
return False
|
||
|
||
|
||
@admin.register(FinancialReport)
|
||
class FinancialReportAdmin(admin.ModelAdmin):
|
||
"""Admin для финансовых отчетов ФНС."""
|
||
|
||
change_list_template = "admin/parsers/financialreport/change_list.html"
|
||
list_display = [
|
||
"external_id",
|
||
"ogrn",
|
||
"registry_organization",
|
||
"file_name",
|
||
"status_badge",
|
||
"source",
|
||
"lines_count",
|
||
"load_batch",
|
||
"created_at",
|
||
]
|
||
list_filter = ["status", "source", "load_batch", "registry_organization", "created_at"]
|
||
search_fields = [
|
||
"external_id",
|
||
"ogrn",
|
||
"file_name",
|
||
"registry_organization__pn_name",
|
||
"registry_organization__mn_ogrn",
|
||
"registry_organization__mn_inn",
|
||
]
|
||
list_select_related = ["registry_organization"]
|
||
readonly_fields = [
|
||
"external_id",
|
||
"ogrn",
|
||
"registry_organization",
|
||
"file_name",
|
||
"file_hash",
|
||
"load_batch",
|
||
"status",
|
||
"source",
|
||
"error_message",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
ordering = ["-created_at"]
|
||
list_per_page = 50
|
||
date_hierarchy = "created_at"
|
||
inlines = [FinancialReportLineInline]
|
||
|
||
fieldsets = (
|
||
(
|
||
"Основное",
|
||
{
|
||
"fields": (
|
||
"external_id",
|
||
"ogrn",
|
||
"registry_organization",
|
||
"file_name",
|
||
"file_hash",
|
||
)
|
||
},
|
||
),
|
||
(
|
||
"Статус",
|
||
{"fields": ("status", "source", "error_message")},
|
||
),
|
||
(
|
||
"Системное",
|
||
{
|
||
"fields": ("load_batch", "created_at", "updated_at"),
|
||
"classes": ("collapse",),
|
||
},
|
||
),
|
||
)
|
||
|
||
def lines_count(self, obj):
|
||
"""Количество строк в отчете."""
|
||
return obj.lines.count()
|
||
|
||
lines_count.short_description = "Строк"
|
||
|
||
def status_badge(self, obj):
|
||
"""Цветной бейдж статуса."""
|
||
colors = {
|
||
"pending": "#6c757d",
|
||
"processing": "#ffc107",
|
||
"success": "#28a745",
|
||
"failed": "#dc3545",
|
||
}
|
||
color = colors.get(obj.status, "#6c757d")
|
||
return format_html(
|
||
'<span style="color: white; background: {}; padding: 3px 10px; '
|
||
'border-radius: 3px;">{}</span>',
|
||
color,
|
||
obj.get_status_display(),
|
||
)
|
||
|
||
status_badge.short_description = "Статус"
|
||
status_badge.admin_order_field = "status"
|
||
|
||
def get_urls(self):
|
||
urls = super().get_urls()
|
||
custom_urls = [
|
||
path(
|
||
"upload-excel/",
|
||
self.admin_site.admin_view(self.upload_excel_view),
|
||
name="parsers_financialreport_upload_excel",
|
||
),
|
||
]
|
||
return custom_urls + urls
|
||
|
||
def changelist_view(self, request, extra_context=None):
|
||
extra_context = extra_context or {}
|
||
extra_context["upload_excel_url"] = reverse(
|
||
"admin:parsers_financialreport_upload_excel"
|
||
)
|
||
return super().changelist_view(request, extra_context=extra_context)
|
||
|
||
def upload_excel_view(self, request):
|
||
changelist_url = reverse("admin:parsers_financialreport_changelist")
|
||
|
||
if request.method == "POST":
|
||
files = request.FILES.getlist("files")
|
||
serializer = FNSFileUploadSerializer(data={"files": files})
|
||
|
||
if not serializer.is_valid():
|
||
self.message_user(
|
||
request,
|
||
f"Ошибка валидации файлов: {serializer.errors}",
|
||
level=messages.ERROR,
|
||
)
|
||
return redirect(changelist_url)
|
||
|
||
try:
|
||
queued, skipped, task_ids = self._enqueue_fns_files(
|
||
request,
|
||
serializer.validated_data["files"],
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
self.message_user(
|
||
request,
|
||
f"Ошибка постановки файлов в очередь: {exc}",
|
||
level=messages.ERROR,
|
||
)
|
||
return redirect(changelist_url)
|
||
|
||
if queued:
|
||
self.message_user(
|
||
request,
|
||
f"Файлов поставлено в очередь: {queued}. Task IDs: {', '.join(task_ids[:5])}",
|
||
level=messages.SUCCESS,
|
||
)
|
||
if skipped:
|
||
self.message_user(
|
||
request,
|
||
f"Пропущено файлов: {skipped} (дубликаты или уже обрабатываются).",
|
||
level=messages.WARNING,
|
||
)
|
||
if not queued and not skipped:
|
||
self.message_user(
|
||
request,
|
||
"Файлы не были обработаны.",
|
||
level=messages.WARNING,
|
||
)
|
||
|
||
return redirect(changelist_url)
|
||
|
||
context = {
|
||
**self.admin_site.each_context(request),
|
||
"opts": self.model._meta,
|
||
"title": "Загрузка Excel отчетности ФНС",
|
||
"changelist_url": changelist_url,
|
||
}
|
||
return TemplateResponse(
|
||
request,
|
||
"admin/parsers/financialreport/upload_excel.html",
|
||
context,
|
||
)
|
||
|
||
@staticmethod
|
||
def _try_create_fns_lock(file_path: Path) -> bool:
|
||
lock_path = Path(f"{file_path}.lock")
|
||
if lock_path.exists():
|
||
try:
|
||
age_seconds = time.time() - lock_path.stat().st_mtime
|
||
ttl_seconds = getattr(settings, "FNS_LOCK_TTL_SECONDS", 3600)
|
||
if age_seconds > ttl_seconds:
|
||
lock_path.unlink()
|
||
else:
|
||
return False
|
||
except FileNotFoundError:
|
||
pass
|
||
try:
|
||
lock_path.touch(exist_ok=False)
|
||
except FileExistsError:
|
||
return False
|
||
return True
|
||
|
||
def _enqueue_fns_files(self, request, files):
|
||
upload_dir = Path(settings.FNS_WATCH_DIRECTORY)
|
||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
task_ids = []
|
||
queued = 0
|
||
skipped = 0
|
||
|
||
for file in files:
|
||
file_content = file.read()
|
||
file_hash = hashlib.sha256(file_content).hexdigest()
|
||
file.seek(0)
|
||
|
||
if FNSReportService.exists_by_hash(file_hash):
|
||
skipped += 1
|
||
continue
|
||
|
||
file_path = upload_dir / file.name
|
||
if not self._try_create_fns_lock(file_path):
|
||
skipped += 1
|
||
continue
|
||
|
||
lock_path = Path(f"{file_path}.lock")
|
||
if file_path.exists():
|
||
lock_path.unlink(missing_ok=True)
|
||
skipped += 1
|
||
continue
|
||
|
||
try:
|
||
with open(file_path, "wb") as f:
|
||
for chunk in file.chunks():
|
||
f.write(chunk)
|
||
except Exception:
|
||
lock_path.unlink(missing_ok=True)
|
||
raise
|
||
|
||
task_id = str(uuid.uuid4())
|
||
try:
|
||
BackgroundJobService.create_job(
|
||
task_id=task_id,
|
||
task_name="apps.parsers.tasks.process_fns_file",
|
||
user_id=request.user.id,
|
||
meta={
|
||
"source": "fns_reports",
|
||
"file": file.name,
|
||
},
|
||
)
|
||
task = process_fns_file.apply_async(
|
||
args=[str(file_path)],
|
||
kwargs={"requested_by_id": request.user.id},
|
||
task_id=task_id,
|
||
)
|
||
except Exception:
|
||
lock_path.unlink(missing_ok=True)
|
||
BackgroundJob.objects.filter(task_id=task_id).delete()
|
||
raise
|
||
|
||
task_ids.append(task.id)
|
||
queued += 1
|
||
|
||
return queued, skipped, task_ids
|
||
|
||
def has_add_permission(self, request):
|
||
"""Запретить создание записей вручную."""
|
||
return False
|
||
|
||
def has_change_permission(self, request, obj=None):
|
||
"""Запретить редактирование записей."""
|
||
return False
|