{{ card.caption }}
+diff --git a/src/apps/core/admin_dashboard.py b/src/apps/core/admin_dashboard.py new file mode 100644 index 0000000..4c4f7fc --- /dev/null +++ b/src/apps/core/admin_dashboard.py @@ -0,0 +1,439 @@ +"""Aggregated data for the custom admin dashboard.""" + +from __future__ import annotations + +from typing import Any + +from apps.parsers.models import ParserLoadLog, ProcurementRecord, Proxy +from apps.parsers.source_cards import SourceCardService +from apps.registers.models import ( + Organization, + Register, + RegisterUpload, + RegistryMembershipPeriod, +) +from django.db.models import Count, Max, Q +from django.urls import NoReverseMatch, reverse + +SOURCE_COLORS = ( + "#49d0c8", + "#7ea6ff", + "#ffc857", + "#ff7aa2", + "#89f0c7", + "#6ed7ff", +) + +STATUS_TONES = { + "success": "success", + "in_progress": "warning", + "error": "danger", + "idle": "muted", + "unavailable": "muted", +} + +REGION_NAMES = { + "01": "Адыгея", + "02": "Башкортостан", + "03": "Бурятия", + "04": "Алтай", + "05": "Дагестан", + "06": "Ингушетия", + "07": "Кабардино-Балкария", + "08": "Калмыкия", + "09": "Карачаево-Черкесия", + "10": "Карелия", + "11": "Коми", + "12": "Марий Эл", + "13": "Мордовия", + "14": "Саха (Якутия)", + "15": "Северная Осетия", + "16": "Татарстан", + "17": "Тыва", + "18": "Удмуртия", + "19": "Хакасия", + "20": "Чечня", + "21": "Чувашия", + "22": "Алтайский край", + "23": "Краснодарский край", + "24": "Красноярский край", + "25": "Приморский край", + "26": "Ставропольский край", + "27": "Хабаровский край", + "28": "Амурская область", + "29": "Архангельская область", + "30": "Астраханская область", + "31": "Белгородская область", + "32": "Брянская область", + "33": "Владимирская область", + "34": "Волгоградская область", + "35": "Вологодская область", + "36": "Воронежская область", + "37": "Ивановская область", + "38": "Иркутская область", + "39": "Калининградская область", + "40": "Калужская область", + "41": "Камчатский край", + "42": "Кемеровская область", + "43": "Кировская область", + "44": "Костромская область", + "45": "Курганская область", + "46": "Курская область", + "47": "Ленинградская область", + "48": "Липецкая область", + "49": "Магаданская область", + "50": "Московская область", + "51": "Мурманская область", + "52": "Нижегородская область", + "53": "Новгородская область", + "54": "Новосибирская область", + "55": "Омская область", + "56": "Оренбургская область", + "57": "Орловская область", + "58": "Пензенская область", + "59": "Пермский край", + "60": "Псковская область", + "61": "Ростовская область", + "62": "Рязанская область", + "63": "Самарская область", + "64": "Саратовская область", + "65": "Сахалинская область", + "66": "Свердловская область", + "67": "Смоленская область", + "68": "Тамбовская область", + "69": "Тверская область", + "70": "Томская область", + "71": "Тульская область", + "72": "Тюменская область", + "73": "Ульяновская область", + "74": "Челябинская область", + "75": "Забайкальский край", + "76": "Ярославская область", + "77": "Москва", + "78": "Санкт-Петербург", + "79": "Еврейская автономная область", + "82": "Республика Крым", + "83": "Ненецкий АО", + "86": "ХМАО", + "87": "Чукотский АО", + "89": "ЯНАО", + "91": "Севастополь", + "92": "Севастополь", +} + + +def build_admin_dashboard() -> dict[str, Any]: + """Return aggregated data for the admin index page.""" + + source_cards = _build_source_cards() + source_mix = _build_source_mix(source_cards) + active_registry_orgs = ( + RegistryMembershipPeriod.objects.filter(ended_at__isnull=True) + .values("organization_id") + .distinct() + .count() + ) + total_organizations = Organization.objects.count() + healthy_sources = sum(1 for card in source_cards if card["status"] == "success") + problem_sources = sum(1 for card in source_cards if card["status"] == "error") + running_sources = sum(1 for card in source_cards if card["status"] == "in_progress") + total_records = sum(card["records_count"] for card in source_cards) + total_uploads = RegisterUpload.objects.count() + active_proxies = Proxy.objects.filter(is_active=True).count() + last_registry_upload_at = RegisterUpload.objects.aggregate( + last_upload=Max("created_at") + )["last_upload"] + + return { + "overview_cards": [ + { + "label": "Организации", + "value": _format_int(total_organizations), + "caption": "Канонические карточки компаний в системе", + "tone": "cyan", + }, + { + "label": "Активно в реестрах", + "value": _format_int(active_registry_orgs), + "caption": "Организации с актуальным членством хотя бы в одном реестре", + "tone": "blue", + }, + { + "label": "Записи источников", + "value": _format_int(total_records), + "caption": "Суммарный объём данных по внешним источникам", + "tone": "amber", + }, + { + "label": "Источники в норме", + "value": f"{healthy_sources}/{len(source_cards)}", + "caption": ( + f"Ошибок: {problem_sources}, в работе: {running_sources}" + if source_cards + else "Подключённых источников пока нет" + ), + "tone": "rose", + }, + ], + "hero_stats": [ + { + "label": "Загрузок реестров", + "value": _format_int(total_uploads), + }, + { + "label": "Активных прокси", + "value": _format_int(active_proxies), + }, + { + "label": "Последняя загрузка реестра", + "value": last_registry_upload_at, + }, + ], + "quick_actions": [ + item + for item in ( + _build_quick_action( + label="Организации", + description="Просмотр канонического списка организаций", + url_name="admin:registers_organization_changelist", + ), + _build_quick_action( + label="Загрузить реестр", + description="Синхронный импорт Excel по выбранному реестру", + url_name="admin:registers_registerupload_upload_excel", + ), + _build_quick_action( + label="ФНС Excel", + description="Загрузить один или несколько файлов отчётности", + url_name="admin:parsers_financialreport_upload_excel", + ), + _build_quick_action( + label="Логи источников", + description="Проверить последние загрузки и ошибки парсеров", + url_name="admin:parsers_parserloadlog_changelist", + ), + ) + if item is not None + ], + "source_cards": source_cards, + "source_mix": source_mix, + "registry_rows": _build_registry_rows(), + "region_rows": _build_region_rows(), + "activity_feed": _build_activity_feed(), + } + + +def _build_source_cards() -> list[dict[str, Any]]: + cards = sorted(SourceCardService.list_cards(), key=lambda item: item["order"]) + total_records = sum(card["records_count"] for card in cards) + max_records = max((card["records_count"] for card in cards), default=0) + max_organizations = max((card["organizations_count"] for card in cards), default=0) + + enriched_cards = [] + for index, card in enumerate(cards): + color = SOURCE_COLORS[index % len(SOURCE_COLORS)] + enriched_cards.append( + { + **card, + "color": color, + "status_tone": STATUS_TONES.get(card["status"], "muted"), + "records_count_label": _format_int(card["records_count"]), + "organizations_count_label": _format_int(card["organizations_count"]), + "records_share": round( + (card["records_count"] / total_records) * 100, 1 + ) + if total_records + else 0, + "records_bar_width": _relative_width( + value=card["records_count"], + max_value=max_records, + ), + "organizations_bar_width": _relative_width( + value=card["organizations_count"], + max_value=max_organizations, + ), + } + ) + + return enriched_cards + + +def _build_source_mix(source_cards: list[dict[str, Any]]) -> dict[str, Any]: + non_empty_cards = [card for card in source_cards if card["records_count"] > 0] + total_records = sum(card["records_count"] for card in non_empty_cards) + if not non_empty_cards or total_records == 0: + return { + "total_records_label": "0", + "segments": [], + "background": ( + "conic-gradient(" + "from 210deg, " + "rgba(73, 208, 200, 0.18), " + "rgba(126, 166, 255, 0.08), " + "rgba(73, 208, 200, 0.18)" + ")" + ), + } + + cursor = 0.0 + gradient_parts: list[str] = [] + segments = [] + for card in sorted(non_empty_cards, key=lambda item: item["records_count"], reverse=True): + share = (card["records_count"] / total_records) * 100 + start = cursor + end = start + share + gradient_parts.append( + f"{card['color']} {start:.2f}% {end:.2f}%" + ) + cursor = end + segments.append( + { + "title": card["title"], + "value": card["records_count_label"], + "share": round(share, 1), + "color": card["color"], + } + ) + + return { + "total_records_label": _format_int(total_records), + "segments": segments, + "background": f"conic-gradient(from 210deg, {', '.join(gradient_parts)})", + } + + +def _build_registry_rows() -> list[dict[str, Any]]: + registries = list( + Register.objects.annotate( + active_organizations=Count( + "membership_periods__organization", + filter=Q(membership_periods__ended_at__isnull=True), + distinct=True, + ), + uploads_count=Count("uploads", distinct=True), + last_upload_at=Max("uploads__created_at"), + last_actual_date=Max("uploads__actual_date"), + ) + .order_by("-active_organizations", "name")[:6] + ) + max_active = max((registry.active_organizations for registry in registries), default=0) + + return [ + { + "name": registry.name, + "active_organizations": registry.active_organizations, + "active_organizations_label": _format_int(registry.active_organizations), + "uploads_count": registry.uploads_count, + "uploads_count_label": _format_int(registry.uploads_count), + "last_upload_at": registry.last_upload_at, + "last_actual_date": registry.last_actual_date, + "bar_width": _relative_width( + value=registry.active_organizations, + max_value=max_active, + ), + } + for registry in registries + ] + + +def _build_region_rows() -> list[dict[str, Any]]: + regions = list( + ProcurementRecord.objects.exclude(region_code="") + .values("region_code") + .annotate( + records_count=Count("id"), + organizations_count=Count("customer_inn", distinct=True), + ) + .order_by("-records_count", "region_code")[:8] + ) + max_records = max((item["records_count"] for item in regions), default=0) + + return [ + { + "code": item["region_code"], + "name": REGION_NAMES.get( + item["region_code"], f"Регион {item['region_code']}" + ), + "records_count": item["records_count"], + "records_count_label": _format_int(item["records_count"]), + "organizations_count": item["organizations_count"], + "organizations_count_label": _format_int(item["organizations_count"]), + "bar_width": _relative_width( + value=item["records_count"], + max_value=max_records, + ), + } + for item in regions + ] + + +def _build_activity_feed() -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + + for load in ParserLoadLog.objects.order_by("-created_at")[:5]: + events.append( + { + "timestamp": load.created_at, + "title": ( + SourceCardService.get_card_title_by_parser_source(load.source) + or load.get_source_display() + ), + "kind": "Источник", + "meta": f"Пакет #{load.batch_id} · {_format_int(load.records_count)} записей", + "note": load.error_message or load.status, + "tone": STATUS_TONES.get(load.status, "muted"), + } + ) + + for upload in RegisterUpload.objects.select_related("registry").order_by("-created_at")[:5]: + events.append( + { + "timestamp": upload.created_at, + "title": upload.registry.name, + "kind": "Реестр", + "meta": ( + f"Срез на {upload.actual_date:%d.%m.%Y} · " + f"{_format_int(upload.rows_count)} строк" + ), + "note": upload.file_name, + "tone": "cyan", + } + ) + + events.sort(key=lambda item: item["timestamp"], reverse=True) + return events[:8] + + +def _build_quick_action( + *, + label: str, + description: str, + url_name: str, +) -> dict[str, str] | None: + url = _safe_reverse(url_name) + if url is None: + return None + return { + "label": label, + "description": description, + "url": url, + } + + +def _relative_width(*, value: int, max_value: int) -> int: + if value <= 0 or max_value <= 0: + return 0 + if value == max_value: + return 100 + return max(12, round((value / max_value) * 100)) + + +def _format_int(value: int) -> str: + return f"{value:,}".replace(",", " ") + + +def _safe_reverse(url_name: str) -> str | None: + try: + return reverse(url_name) + except NoReverseMatch: + return None diff --git a/src/apps/core/context_processors.py b/src/apps/core/context_processors.py new file mode 100644 index 0000000..6ad3691 --- /dev/null +++ b/src/apps/core/context_processors.py @@ -0,0 +1,14 @@ +"""Template context processors for custom admin pages.""" + +from __future__ import annotations + +from apps.core.admin_dashboard import build_admin_dashboard + + +def admin_dashboard(request): + """Inject dashboard data only for the admin index page.""" + + resolver_match = getattr(request, "resolver_match", None) + if resolver_match is None or resolver_match.view_name != "admin:index": + return {} + return {"admin_dashboard_data": build_admin_dashboard()} diff --git a/src/apps/parsers/admin.py b/src/apps/parsers/admin.py index ecf0bb3..3dc7957 100644 --- a/src/apps/parsers/admin.py +++ b/src/apps/parsers/admin.py @@ -15,6 +15,7 @@ from apps.parsers.models import ( Proxy, ) from apps.parsers.serializers import FNSFileUploadSerializer, FNSZipUploadSerializer +from apps.parsers.services import ProxyToolsSyncError, ProxyToolsSyncService from django.contrib import admin, messages from django.shortcuts import redirect from django.template.response import TemplateResponse @@ -26,6 +27,7 @@ from django.utils.html import format_html class ProxyAdmin(admin.ModelAdmin): """Admin для прокси-серверов.""" + change_list_template = "admin/parsers/proxy/change_list.html" list_display = [ "address", "country_code", @@ -73,6 +75,78 @@ class ProxyAdmin(admin.ModelAdmin): is_active_badge.short_description = "Статус" is_active_badge.admin_order_field = "is_active" + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path( + "sync-proxy-tools/", + self.admin_site.admin_view(self.sync_proxy_tools_view), + name="parsers_proxy_sync_proxy_tools", + ), + ] + return custom_urls + urls + + def changelist_view(self, request, extra_context=None): + extra_context = extra_context or {} + extra_context["sync_proxy_tools_url"] = reverse( + "admin:parsers_proxy_sync_proxy_tools" + ) + return super().changelist_view(request, extra_context=extra_context) + + def sync_proxy_tools_view(self, request): + changelist_url = reverse("admin:parsers_proxy_changelist") + + if request.method != "POST": + self.message_user( + request, + "Обновление списка прокси доступно только через POST.", + level=messages.WARNING, + ) + return redirect(changelist_url) + + try: + result = ProxyToolsSyncService.sync_ru_proxies() + except ProxyToolsSyncError as exc: + self.message_user( + request, + f"Ошибка обновления списка прокси: {exc}", + level=messages.ERROR, + ) + return redirect(changelist_url) + + if result.get("status") == "success": + self.message_user( + request, + ( + "Список прокси обновлён: " + f"загружено {result['fetched']}, " + f"создано {result['created']}, " + f"обновлено {result['updated']}, " + f"деактивировано {result['deactivated']}." + ), + level=messages.SUCCESS, + ) + elif result.get("status") == "skipped": + reason = result.get("reason") + message = ( + "Обновление списка прокси пропущено: не задан PROXY_TOOLS_API_KEY." + if reason == "missing_api_key" + else f"Обновление списка прокси пропущено: {reason}." + ) + self.message_user( + request, + message, + level=messages.WARNING, + ) + else: + self.message_user( + request, + "Обновление списка прокси завершилось без результата.", + level=messages.WARNING, + ) + + return redirect(changelist_url) + actions = ["activate_proxies", "deactivate_proxies", "reset_fail_count"] @admin.action(description="Активировать выбранные прокси") @@ -770,6 +844,45 @@ class FinancialReportAdmin(admin.ModelAdmin): ) return super().changelist_view(request, extra_context=extra_context) + def _message_sync_upload_result(self, request, result, *, archive: bool) -> None: + noun = "файлов из архива" if archive else "файлов" + + if result.processed: + self.message_user( + request, + f"Успешно обработано {noun}: {result.processed}.", + level=messages.SUCCESS, + ) + if result.skipped: + self.message_user( + request, + f"Пропущено {noun}: {result.skipped}.", + level=messages.WARNING, + ) + if result.failed: + self.message_user( + request, + f"Не удалось обработать {noun}: {result.failed}.", + level=messages.ERROR, + ) + if result.invalid: + self.message_user( + request, + f"Невалидных элементов в архиве: {result.invalid}.", + level=messages.WARNING, + ) + if not result.processed and not result.skipped and not result.failed: + empty_message = ( + "Архив не содержит подходящих файлов." + if archive + else "Файлы не были обработаны." + ) + self.message_user( + request, + empty_message, + level=messages.WARNING, + ) + def upload_excel_view(self, request): changelist_url = reverse("admin:parsers_financialreport_changelist") @@ -786,39 +899,19 @@ class FinancialReportAdmin(admin.ModelAdmin): return redirect(changelist_url) try: - result = FNSUploadService.queue_uploaded_files( + result = FNSUploadService.process_uploaded_files_sync( files=serializer.validated_data["files"], requested_by_id=request.user.id, ) except Exception as exc: # noqa: BLE001 self.message_user( request, - f"Ошибка постановки файлов в очередь: {exc}", + f"Ошибка синхронной обработки файлов: {exc}", level=messages.ERROR, ) return redirect(changelist_url) - if result.queued: - self.message_user( - request, - "Файлов поставлено в очередь: " - f"{result.queued}. Task IDs: {', '.join(result.task_ids[:5])}", - level=messages.SUCCESS, - ) - if result.skipped: - self.message_user( - request, - "Пропущено файлов: " - f"{result.skipped} (дубликаты или уже обрабатываются).", - level=messages.WARNING, - ) - if not result.queued and not result.skipped: - self.message_user( - request, - "Файлы не были обработаны.", - level=messages.WARNING, - ) - + self._message_sync_upload_result(request, result, archive=False) return redirect(changelist_url) context = { @@ -850,7 +943,7 @@ class FinancialReportAdmin(admin.ModelAdmin): return redirect(changelist_url) try: - result = FNSUploadService.queue_zip_archive( + result = FNSUploadService.process_zip_archive_sync( archive_file=serializer.validated_data["file"], requested_by_id=request.user.id, ) @@ -862,32 +955,7 @@ class FinancialReportAdmin(admin.ModelAdmin): ) return redirect(changelist_url) - if result.queued: - self.message_user( - request, - "Файлов из архива поставлено в очередь: " - f"{result.queued}. Task IDs: {', '.join(result.task_ids[:5])}", - level=messages.SUCCESS, - ) - if result.skipped: - self.message_user( - request, - f"Пропущено файлов из архива: {result.skipped}.", - level=messages.WARNING, - ) - if result.invalid: - self.message_user( - request, - f"Невалидных элементов в архиве: {result.invalid}.", - level=messages.WARNING, - ) - if not result.queued and not result.skipped and not result.invalid: - self.message_user( - request, - "Архив не содержит подходящих файлов.", - level=messages.WARNING, - ) - + self._message_sync_upload_result(request, result, archive=True) return redirect(changelist_url) context = { diff --git a/src/apps/parsers/fns_upload.py b/src/apps/parsers/fns_upload.py index d2ba6fc..846278d 100644 --- a/src/apps/parsers/fns_upload.py +++ b/src/apps/parsers/fns_upload.py @@ -30,6 +30,16 @@ class FNSUploadResult: task_ids: list[str] = field(default_factory=list) +@dataclass +class FNSSyncUploadResult: + """Result of synchronous FNS file processing.""" + + processed: int = 0 + skipped: int = 0 + invalid: int = 0 + failed: int = 0 + + class FNSUploadService: """Queue uploaded FNS Excel files and ZIP archives for processing.""" @@ -87,6 +97,60 @@ class FNSUploadService: return result + @classmethod + def process_uploaded_files_sync( + cls, *, files, requested_by_id: int | None + ) -> FNSSyncUploadResult: + result = FNSSyncUploadResult() + seen_hashes: set[str] = set() + + for uploaded_file in files: + status = cls._process_file_bytes_sync( + file_name=uploaded_file.name, + file_content=uploaded_file.read(), + requested_by_id=requested_by_id, + seen_hashes=seen_hashes, + ) + cls._accumulate_sync(result=result, status=status) + + return result + + @classmethod + def process_zip_archive_sync( + cls, + *, + archive_file, + requested_by_id: int | None, + ) -> FNSSyncUploadResult: + result = FNSSyncUploadResult() + seen_hashes: set[str] = set() + + archive_file.seek(0) + try: + with zipfile.ZipFile(archive_file) as archive: + for member in archive.infolist(): + if member.is_dir(): + continue + + file_name = cls._extract_member_name(member.filename) + if not file_name or not FNS_XLSX_FILENAME_RE.match(file_name): + result.invalid += 1 + continue + + status = cls._process_file_bytes_sync( + file_name=file_name, + file_content=archive.read(member), + requested_by_id=requested_by_id, + seen_hashes=seen_hashes, + ) + cls._accumulate_sync(result=result, status=status) + except zipfile.BadZipFile as exc: + raise ValueError( + "Загруженный файл не является корректным ZIP архивом" + ) from exc + + return result + @staticmethod def _extract_member_name(member_name: str) -> str | None: path = PurePosixPath(member_name) @@ -106,27 +170,15 @@ class FNSUploadService: requested_by_id: int | None, seen_hashes: set[str], ) -> tuple[str, str | None]: - file_hash = hashlib.sha256(file_content).hexdigest() - if file_hash in seen_hashes or FNSReportService.exists_by_hash(file_hash): + status, file_path, file_hash = cls._prepare_file_bytes( + file_name=file_name, + file_content=file_content, + seen_hashes=seen_hashes, + ) + if status == "skipped": return "skipped", None - - upload_dir = Path(settings.FNS_WATCH_DIRECTORY) - upload_dir.mkdir(parents=True, exist_ok=True) - - file_path = upload_dir / file_name - if not cls._try_create_fns_lock(file_path): - return "skipped", None - - lock_path = Path(f"{file_path}.lock") - if file_path.exists(): - lock_path.unlink(missing_ok=True) - return "skipped", None - - try: - file_path.write_bytes(file_content) - except Exception: - lock_path.unlink(missing_ok=True) - raise + if file_path is None or file_hash is None: # pragma: no cover + raise RuntimeError("Prepared FNS file is missing processing metadata") task_id = str(uuid.uuid4()) try: @@ -145,13 +197,83 @@ class FNSUploadService: task_id=task_id, ) except Exception: - lock_path.unlink(missing_ok=True) + Path(f"{file_path}.lock").unlink(missing_ok=True) BackgroundJob.objects.filter(task_id=task_id).delete() raise seen_hashes.add(file_hash) return "queued", task.id + @classmethod + def _process_file_bytes_sync( + cls, + *, + file_name: str, + file_content: bytes, + requested_by_id: int | None, + seen_hashes: set[str], + ) -> str: + from apps.parsers.tasks import _process_fns_file_sync + + status, file_path, file_hash = cls._prepare_file_bytes( + file_name=file_name, + file_content=file_content, + seen_hashes=seen_hashes, + ) + if status == "skipped": + return "skipped" + if file_path is None or file_hash is None: # pragma: no cover + raise RuntimeError("Prepared FNS file is missing processing metadata") + + result = _process_fns_file_sync( + str(file_path), + task_id=str(uuid.uuid4()), + requested_by_id=requested_by_id, + raise_on_error=False, + ) + result_status = result.get("status") + if result_status == "success": + if file_hash is not None: + seen_hashes.add(file_hash) + return "processed" + if result_status == "skipped": + if file_hash is not None: + seen_hashes.add(file_hash) + return "skipped" + return "failed" + + @classmethod + def _prepare_file_bytes( + cls, + *, + file_name: str, + file_content: bytes, + seen_hashes: set[str], + ) -> tuple[str, Path | None, str | None]: + file_hash = hashlib.sha256(file_content).hexdigest() + if file_hash in seen_hashes or FNSReportService.exists_by_hash(file_hash): + return "skipped", None, None + + upload_dir = Path(settings.FNS_WATCH_DIRECTORY) + upload_dir.mkdir(parents=True, exist_ok=True) + + file_path = upload_dir / file_name + if not cls._try_create_fns_lock(file_path): + return "skipped", None, None + + lock_path = Path(f"{file_path}.lock") + if file_path.exists(): + lock_path.unlink(missing_ok=True) + return "skipped", None, None + + try: + file_path.write_bytes(file_content) + except Exception: + lock_path.unlink(missing_ok=True) + raise + + return "prepared", file_path, file_hash + @staticmethod def _try_create_fns_lock(file_path: Path) -> bool: lock_path = Path(f"{file_path}.lock") @@ -182,3 +304,14 @@ class FNSUploadService: return if status == "skipped": result.skipped += 1 + + @staticmethod + def _accumulate_sync(*, result: FNSSyncUploadResult, status: str) -> None: + if status == "processed": + result.processed += 1 + return + if status == "skipped": + result.skipped += 1 + return + if status == "failed": + result.failed += 1 diff --git a/src/apps/registers/migrations/0005_seed_additional_registers.py b/src/apps/registers/migrations/0005_seed_additional_registers.py new file mode 100644 index 0000000..4da2455 --- /dev/null +++ b/src/apps/registers/migrations/0005_seed_additional_registers.py @@ -0,0 +1,29 @@ +from django.db import migrations + +ADDITIONAL_REGISTER_NAMES = ( + "Реестр госкорпорации Роскосмос ГОЗ", + "Реестр госкорпорации Роскосмос ОПК", + "Реестр госкорпорации Росатом ГОЗ", + "Реестр госкорпорации Росатом ОПК", +) + + +def seed_additional_registers(apps, schema_editor): + Register = apps.get_model("registers", "Register") + db_alias = schema_editor.connection.alias + + for name in ADDITIONAL_REGISTER_NAMES: + Register.objects.using(db_alias).get_or_create(name=name) + + +class Migration(migrations.Migration): + dependencies = [ + ("registers", "0004_seed_default_registers"), + ] + + operations = [ + migrations.RunPython( + seed_additional_registers, + migrations.RunPython.noop, + ), + ] diff --git a/src/apps/registers/signals.py b/src/apps/registers/signals.py index cb22d4e..7afb645 100644 --- a/src/apps/registers/signals.py +++ b/src/apps/registers/signals.py @@ -7,7 +7,11 @@ from django.dispatch import receiver DEFAULT_REGISTER_NAMES = ( "Реестр предприятий ОПК", "Реестр госкорпорации Роскосмос", + "Реестр госкорпорации Роскосмос ГОЗ", + "Реестр госкорпорации Роскосмос ОПК", "Реестр госкорпорации Росатом", + "Реестр госкорпорации Росатом ГОЗ", + "Реестр госкорпорации Росатом ОПК", ) diff --git a/src/apps/user/admin.py b/src/apps/user/admin.py index f7fd305..636dd61 100644 --- a/src/apps/user/admin.py +++ b/src/apps/user/admin.py @@ -63,8 +63,7 @@ class UserAdmin(BaseUserAdmin): "is_verified", "groups", "user_permissions", - ), - "classes": ("collapse",), + ) }, ), ( @@ -84,6 +83,7 @@ class UserAdmin(BaseUserAdmin): "password1", "password2", "is_staff", + "is_superuser", "is_active", ), }, diff --git a/src/settings/base.py b/src/settings/base.py index 9423df1..c357104 100644 --- a/src/settings/base.py +++ b/src/settings/base.py @@ -182,6 +182,7 @@ TEMPLATES = [ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", + "apps.core.context_processors.admin_dashboard", ], }, }, diff --git a/src/static/admin/css/mostovik-admin-dashboard.css b/src/static/admin/css/mostovik-admin-dashboard.css new file mode 100644 index 0000000..b52f5ff --- /dev/null +++ b/src/static/admin/css/mostovik-admin-dashboard.css @@ -0,0 +1,788 @@ +.mx-dashboard { + display: grid; + gap: 1.25rem; + padding-bottom: 1.5rem; +} + +.mx-tabs { + display: grid; + gap: 1rem; +} + +.mx-tab-toggle { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.mx-tab-nav { + display: inline-flex; + gap: 0.55rem; + width: fit-content; + max-width: 100%; + padding: 0.45rem; + overflow-x: auto; + border: 1px solid rgba(73, 208, 200, 0.12); + border-radius: 999px; + background: rgba(6, 21, 28, 0.72); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); +} + +.mx-tab-label { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.55rem; + padding: 0.55rem 1rem; + border-radius: 999px; + color: var(--mx-text-muted); + font-size: 0.88rem; + font-weight: 700; + letter-spacing: 0.03em; + white-space: nowrap; + cursor: pointer; + transition: + color 0.18s ease, + background-color 0.18s ease, + transform 0.18s ease, + box-shadow 0.18s ease; +} + +.mx-tab-label:hover { + color: #f8ffff; + transform: translateY(-1px); +} + +#mx-tab-overview:checked ~ .mx-tab-nav label[for="mx-tab-overview"], +#mx-tab-analytics:checked ~ .mx-tab-nav label[for="mx-tab-analytics"], +#mx-tab-admin:checked ~ .mx-tab-nav label[for="mx-tab-admin"] { + color: #f9ffff; + background: linear-gradient(135deg, rgba(73, 208, 200, 0.22), rgba(126, 166, 255, 0.18)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 10px 24px rgba(1, 8, 11, 0.22); +} + +.mx-tab-panels { + display: grid; +} + +.mx-tab-panel { + display: none; + gap: 1rem; + animation: mx-dashboard-rise 0.35s ease both; +} + +#mx-tab-overview:checked ~ .mx-tab-panels .mx-tab-panel--overview, +#mx-tab-analytics:checked ~ .mx-tab-panels .mx-tab-panel--analytics, +#mx-tab-admin:checked ~ .mx-tab-panels .mx-tab-panel--admin { + display: grid; +} + +.mx-hero, +.mx-panel, +.mx-stat-card { + position: relative; + overflow: hidden; + border: 1px solid rgba(73, 208, 200, 0.14); + border-radius: 24px; + background: + linear-gradient(180deg, rgba(6, 21, 28, 0.94), rgba(5, 16, 22, 0.98)), + radial-gradient(circle at top right, rgba(126, 166, 255, 0.16), transparent 32%); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.03), + 0 24px 60px rgba(1, 8, 11, 0.38); +} + +.mx-hero::before, +.mx-panel::before, +.mx-stat-card::before { + content: ""; + position: absolute; + inset: 0; + background: + radial-gradient(circle at top left, rgba(73, 208, 200, 0.08), transparent 32%), + linear-gradient(135deg, rgba(255, 255, 255, 0.02), transparent 36%); + pointer-events: none; +} + +.mx-hero, +.mx-main-grid, +.mx-split-grid, +.mx-stat-card, +.mx-panel, +.mx-source-card, +.mx-app-card { + animation: mx-dashboard-rise 0.6s ease both; +} + +.mx-dashboard > *:nth-child(2) { + animation-delay: 0.06s; +} + +.mx-dashboard > *:nth-child(3) { + animation-delay: 0.12s; +} + +.mx-dashboard > *:nth-child(4) { + animation-delay: 0.18s; +} + +.mx-dashboard > *:nth-child(5) { + animation-delay: 0.24s; +} + +.mx-hero { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr); + gap: 1.5rem; + padding: 1.7rem; +} + +.mx-hero__copy, +.mx-hero__visual, +.mx-panel > *, +.mx-stat-card > * { + position: relative; + z-index: 1; +} + +.mx-eyebrow { + margin: 0 0 0.45rem; + color: var(--mx-text-dim); + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.mx-hero__title { + margin: 0; + color: #f7fffe; + font-size: clamp(1.8rem, 3vw, 2.8rem); + font-weight: 700; + line-height: 1.05; +} + +.mx-hero__lead { + max-width: 52rem; + margin: 0.95rem 0 0; + color: var(--mx-text-muted); + font-size: 1rem; + line-height: 1.65; +} + +.mx-hero__facts { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.85rem; + margin-top: 1.4rem; +} + +.mx-hero__fact { + padding: 0.95rem 1rem; + border: 1px solid rgba(73, 208, 200, 0.1); + border-radius: 18px; + background: rgba(6, 23, 28, 0.72); + backdrop-filter: blur(10px); +} + +.mx-hero__fact-label, +.mx-panel__note, +.mx-source-card__metrics span, +.mx-bar-item__head span, +.mx-feed-item__meta time, +.mx-feed-item__note, +.mx-ring-card__legend-item span, +.mx-action-card span, +.mx-stat-card__caption, +.mx-app-card__head span { + color: var(--mx-text-dim); +} + +.mx-hero__fact-label, +.mx-stat-card__label { + display: block; + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.mx-hero__fact-value { + display: block; + margin-top: 0.4rem; + color: #f6fffe; + font-size: 1.05rem; + font-weight: 700; + line-height: 1.3; +} + +.mx-hero__visual { + display: flex; + align-items: stretch; +} + +.mx-ring-card { + display: grid; + gap: 1rem; + width: 100%; + min-height: 100%; + padding: 1.2rem; + border: 1px solid rgba(73, 208, 200, 0.1); + border-radius: 22px; + background: + linear-gradient(180deg, rgba(8, 27, 33, 0.84), rgba(5, 18, 24, 0.92)), + radial-gradient(circle at top, rgba(126, 166, 255, 0.18), transparent 38%); +} + +.mx-ring-chart { + position: relative; + width: min(290px, 100%); + aspect-ratio: 1; + margin: 0 auto; + border-radius: 50%; + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.04), + 0 16px 50px rgba(0, 0, 0, 0.25); +} + +.mx-ring-chart::before { + content: ""; + position: absolute; + inset: 20%; + border-radius: 50%; + background: + radial-gradient(circle at 30% 30%, rgba(73, 208, 200, 0.16), transparent 34%), + linear-gradient(180deg, rgba(5, 18, 23, 0.96), rgba(4, 14, 18, 0.98)); + border: 1px solid rgba(73, 208, 200, 0.08); +} + +.mx-ring-chart__center { + position: absolute; + inset: 0; + display: grid; + align-content: center; + justify-items: center; + gap: 0.15rem; + text-align: center; +} + +.mx-ring-chart__center span { + color: var(--mx-text-dim); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.mx-ring-chart__center strong { + color: #f8ffff; + font-size: clamp(1.5rem, 2vw, 2.2rem); + line-height: 1; +} + +.mx-ring-card__legend { + display: grid; + gap: 0.75rem; +} + +.mx-ring-card__legend-item { + display: grid; + grid-template-columns: 12px minmax(0, 1fr); + gap: 0.75rem; + align-items: start; +} + +.mx-ring-card__legend-item strong, +.mx-bar-item__head strong, +.mx-feed-item strong, +.mx-action-card strong, +.mx-app-card__head h3 { + color: #f5fffe; +} + +.mx-dot { + display: inline-block; + width: 12px; + height: 12px; + margin-top: 0.28rem; + border-radius: 999px; + box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.03); +} + +.mx-overview-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; +} + +.mx-stat-card { + padding: 1.15rem 1.2rem; +} + +.mx-stat-card__value { + display: block; + margin-top: 0.45rem; + color: #fbffff; + font-size: clamp(1.5rem, 2vw, 2rem); + line-height: 1; +} + +.mx-stat-card__caption { + margin: 0.8rem 0 0; + line-height: 1.55; +} + +.mx-stat-card--cyan { + background: + linear-gradient(180deg, rgba(7, 25, 32, 0.95), rgba(5, 16, 22, 0.98)), + radial-gradient(circle at top right, rgba(73, 208, 200, 0.18), transparent 40%); +} + +.mx-stat-card--blue { + background: + linear-gradient(180deg, rgba(7, 25, 32, 0.95), rgba(5, 16, 22, 0.98)), + radial-gradient(circle at top right, rgba(126, 166, 255, 0.18), transparent 40%); +} + +.mx-stat-card--amber { + background: + linear-gradient(180deg, rgba(7, 25, 32, 0.95), rgba(5, 16, 22, 0.98)), + radial-gradient(circle at top right, rgba(255, 200, 87, 0.16), transparent 40%); +} + +.mx-stat-card--rose { + background: + linear-gradient(180deg, rgba(7, 25, 32, 0.95), rgba(5, 16, 22, 0.98)), + radial-gradient(circle at top right, rgba(255, 122, 162, 0.16), transparent 40%); +} + +.mx-main-grid, +.mx-split-grid { + display: grid; + gap: 1rem; +} + +.mx-main-grid { + grid-template-columns: minmax(0, 1.25fr) minmax(300px, 0.75fr); +} + +.mx-split-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.mx-side-stack { + display: grid; + gap: 1rem; +} + +.mx-panel { + padding: 1.35rem; +} + +.mx-panel__header { + display: flex; + gap: 1rem; + align-items: start; + justify-content: space-between; + margin-bottom: 1.05rem; +} + +.mx-panel__header h2 { + margin: 0; + color: #f8ffff; + font-size: 1.2rem; + line-height: 1.2; +} + +.mx-panel__note { + max-width: 24rem; + margin: 0; + font-size: 0.92rem; + line-height: 1.55; + text-align: right; +} + +.mx-source-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.mx-source-card, +.mx-action-card, +.mx-app-card, +.mx-feed-item, +.mx-bar-item { + position: relative; + overflow: hidden; + border: 1px solid rgba(73, 208, 200, 0.1); + border-radius: 20px; + background: rgba(6, 23, 29, 0.78); +} + +.mx-source-card { + padding: 1.05rem; +} + +.mx-source-card::before, +.mx-action-card::before, +.mx-app-card::before, +.mx-feed-item::before, +.mx-bar-item::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 4px; + background: var(--mx-accent, rgba(73, 208, 200, 0.65)); + opacity: 0.9; +} + +.mx-source-card__top, +.mx-bar-item__head, +.mx-feed-item__meta, +.mx-app-card__head { + display: flex; + gap: 0.75rem; + align-items: start; + justify-content: space-between; +} + +.mx-status-pill, +.mx-feed-item__kind { + display: inline-flex; + align-items: center; + min-height: 1.7rem; + padding: 0.25rem 0.65rem; + border-radius: 999px; + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.mx-tone--success .mx-status-pill, +.mx-tone--success .mx-feed-item__kind { + background: rgba(45, 212, 191, 0.16); + color: #95fff0; +} + +.mx-tone--warning .mx-status-pill, +.mx-tone--warning .mx-feed-item__kind { + background: rgba(255, 200, 87, 0.16); + color: #ffe09a; +} + +.mx-tone--danger .mx-status-pill, +.mx-tone--danger .mx-feed-item__kind { + background: rgba(255, 107, 159, 0.18); + color: #ffc2d7; +} + +.mx-tone--muted .mx-status-pill, +.mx-tone--muted .mx-feed-item__kind, +.mx-tone--cyan .mx-feed-item__kind { + background: rgba(126, 166, 255, 0.14); + color: #d8e4ff; +} + +.mx-source-card__share { + color: #f8ffff; + font-size: 1.1rem; + font-weight: 700; +} + +.mx-source-card h3, +.mx-app-card__head h3 { + margin: 0.8rem 0 0; + font-size: 1.02rem; + line-height: 1.3; +} + +.mx-source-card p, +.mx-feed-item p, +.mx-action-card span { + margin: 0.55rem 0 0; + line-height: 1.55; +} + +.mx-source-card__metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.85rem; + margin-top: 1rem; +} + +.mx-source-card__metrics strong { + display: block; + margin-top: 0.18rem; + color: #f8ffff; + font-size: 1.05rem; +} + +.mx-meter { + height: 10px; + margin-top: 0.9rem; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); +} + +.mx-meter span { + display: block; + width: var(--mx-bar-value, 0%); + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--mx-accent), rgba(255, 255, 255, 0.68)); +} + +.mx-meter--soft { + margin-top: 0.45rem; +} + +.mx-meter--soft span { + width: var(--mx-org-bar-value, 0%); + opacity: 0.72; +} + +.mx-source-card__footer { + display: grid; + gap: 0.35rem; + margin-top: 0.95rem; + color: var(--mx-text-dim); + font-size: 0.9rem; +} + +.mx-source-card__error { + color: #ffc2d7; +} + +.mx-action-list, +.mx-feed-list, +.mx-bar-list, +.mx-app-grid { + display: grid; + gap: 0.85rem; +} + +.mx-action-card, +.mx-feed-item, +.mx-bar-item { + padding: 1rem; + text-decoration: none !important; + transition: + transform 0.2s ease, + border-color 0.2s ease, + background-color 0.2s ease; +} + +.mx-action-card:hover, +.mx-app-card:hover { + transform: translateY(-2px); + border-color: rgba(73, 208, 200, 0.22); +} + +.mx-action-card strong { + display: block; + font-size: 1rem; +} + +.mx-feed-item__meta { + margin-bottom: 0.55rem; +} + +.mx-feed-item__note { + display: block; + margin-top: 0.45rem; + line-height: 1.45; +} + +.mx-bar-item__head { + margin-bottom: 0.7rem; +} + +.mx-bar-track { + height: 12px; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.05); +} + +.mx-bar-track span { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, rgba(73, 208, 200, 0.95), rgba(126, 166, 255, 0.88)); +} + +.mx-bar-track--violet span { + background: linear-gradient(90deg, rgba(126, 166, 255, 0.95), rgba(255, 122, 162, 0.88)); +} + +.mx-app-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.mx-app-card { + padding: 1rem; +} + +.mx-app-card__head { + margin-bottom: 0.8rem; +} + +.mx-app-card__head h3 { + margin: 0; +} + +.mx-app-card__body { + display: grid; + gap: 0.75rem; +} + +.mx-app-model { + display: grid; + gap: 0.7rem; + padding-top: 0.7rem; + border-top: 1px solid rgba(255, 255, 255, 0.05); +} + +.mx-app-model:first-child { + padding-top: 0; + border-top: 0; +} + +.mx-app-model__name a { + color: #f7fffe; + font-weight: 600; +} + +.mx-app-model__actions { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; +} + +.mx-mini-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2rem; + padding: 0.42rem 0.75rem; + border: 1px solid rgba(73, 208, 200, 0.14); + border-radius: 999px; + background: linear-gradient(135deg, rgba(51, 179, 168, 0.2), rgba(65, 112, 196, 0.16)); + color: #f6fffe !important; + font-size: 0.83rem; + font-weight: 700; + text-decoration: none !important; + transition: + transform 0.2s ease, + border-color 0.2s ease, + filter 0.2s ease; +} + +.mx-mini-button--ghost { + background: rgba(255, 255, 255, 0.03); +} + +.mx-mini-button:hover { + filter: brightness(1.05); + transform: translateY(-1px); +} + +.mx-empty-state { + margin: 0; + padding: 1rem; + border: 1px dashed rgba(73, 208, 200, 0.18); + border-radius: 18px; + color: var(--mx-text-dim); + background: rgba(5, 18, 24, 0.64); +} + +@keyframes mx-dashboard-rise { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 1440px) { + .mx-hero { + grid-template-columns: minmax(0, 1fr); + } + + .mx-hero__visual { + justify-content: center; + } +} + +@media (max-width: 1200px) { + .mx-main-grid, + .mx-split-grid, + .mx-overview-grid, + .mx-source-grid, + .mx-app-grid { + grid-template-columns: minmax(0, 1fr); + } + + .mx-panel__header { + flex-direction: column; + } + + .mx-panel__note { + max-width: none; + text-align: left; + } +} + +@media (max-width: 900px) { + .mx-hero, + .mx-panel { + padding: 1rem; + } + + .mx-hero__facts { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 640px) { + .content-wrapper > .content { + padding-left: 0.55rem; + padding-right: 0.55rem; + } + + .content-wrapper > .content > .container-fluid { + padding-left: 0; + padding-right: 0; + } + + .mx-source-card__metrics, + .mx-bar-item__head, + .mx-feed-item__meta, + .mx-app-card__head, + .mx-source-card__top { + grid-template-columns: minmax(0, 1fr); + display: grid; + } + + .mx-source-card__share { + justify-self: start; + } + + .mx-ring-chart { + width: 100%; + max-width: 250px; + } + + .mx-tab-nav { + width: 100%; + } +} diff --git a/src/static/admin/css/mostovik-admin-theme.css b/src/static/admin/css/mostovik-admin-theme.css index f5dce67..5150469 100644 --- a/src/static/admin/css/mostovik-admin-theme.css +++ b/src/static/admin/css/mostovik-admin-theme.css @@ -405,6 +405,15 @@ a.button:hover, transform: translateY(-1px); } +.object-tools .mx-object-tool-form { + display: inline-flex; + margin: 0; +} + +.object-tools .mx-object-tool-button { + cursor: pointer; +} + .deletelink, .btn-danger { background: linear-gradient(135deg, rgba(255, 107, 159, 0.82), rgba(167, 53, 118, 0.88)); diff --git a/src/static/admin/css/mostovik-admin-upload.css b/src/static/admin/css/mostovik-admin-upload.css new file mode 100644 index 0000000..210d10a --- /dev/null +++ b/src/static/admin/css/mostovik-admin-upload.css @@ -0,0 +1,151 @@ +.mx-upload-page { + max-width: 980px; +} + +.mx-upload-shell { + display: grid; + gap: 1rem; +} + +.mx-upload-intro { + padding: 1.15rem 1.25rem; + border: 1px solid var(--mx-border); + border-radius: 16px; + background: + linear-gradient(135deg, rgba(8, 24, 30, 0.96), rgba(5, 18, 24, 0.9)), + radial-gradient(circle at top right, rgba(73, 208, 200, 0.12), transparent 32%); + box-shadow: var(--mx-shadow); +} + +.mx-upload-title { + margin: 0; + color: #f5fbff; + font-size: 1.35rem; + font-weight: 700; + letter-spacing: 0.01em; +} + +.mx-upload-description { + margin: 0.55rem 0 0; + max-width: 64ch; + color: var(--mx-text-muted); + line-height: 1.55; +} + +.mx-upload-form { + display: grid; + gap: 0.9rem; +} + +.mx-upload-card { + margin: 0; + padding: 1.25rem; + border-radius: 16px; +} + +.mx-upload-grid { + display: grid; + gap: 1rem; +} + +.mx-upload-field { + display: grid; + gap: 0.45rem; +} + +.mx-upload-label { + margin: 0; + color: #d8e7ff; + font-size: 0.95rem; + font-weight: 600; +} + +.mx-upload-input, +.mx-upload-select { + width: min(100%, 520px); + padding: 0.7rem 0.9rem; +} + +.mx-upload-help { + margin: 0; + max-width: 72ch; + color: var(--mx-text-dim) !important; + line-height: 1.45; +} + +.mx-upload-file { + width: min(100%, 720px); + padding: 0.35rem; + cursor: pointer; +} + +.mx-upload-file::file-selector-button { + margin-right: 0.8rem; + padding: 0.82rem 1rem; + border: 1px solid rgba(98, 119, 211, 0.48); + border-radius: 10px; + background: linear-gradient(135deg, rgba(51, 179, 168, 0.9), rgba(65, 112, 196, 0.82)); + color: #ffffff; + font-weight: 700; + cursor: pointer; +} + +.mx-upload-file::-webkit-file-upload-button { + margin-right: 0.8rem; + padding: 0.82rem 1rem; + border: 1px solid rgba(98, 119, 211, 0.48); + border-radius: 10px; + background: linear-gradient(135deg, rgba(51, 179, 168, 0.9), rgba(65, 112, 196, 0.82)); + color: #ffffff; + font-weight: 700; + cursor: pointer; +} + +.mx-upload-actions { + display: flex; + align-items: center; + gap: 0.8rem; + margin: 0; + padding: 1rem 1.15rem; + border: 1px solid var(--mx-border); + border-radius: 14px; + background: rgba(5, 18, 24, 0.84); + box-shadow: var(--mx-shadow); +} + +.mx-upload-cancel { + color: var(--mx-azure); + font-weight: 600; +} + +.mx-upload-cancel:hover { + color: #88d9ff; +} + +@media (max-width: 900px) { + .mx-upload-file::file-selector-button, + .mx-upload-file::-webkit-file-upload-button { + width: 100%; + margin-right: 0; + margin-bottom: 0.65rem; + } +} + +@media (max-width: 640px) { + .mx-upload-card, + .mx-upload-intro, + .mx-upload-actions { + padding: 1rem; + } + + .mx-upload-input, + .mx-upload-select, + .mx-upload-file { + width: 100%; + } + + .mx-upload-actions { + flex-direction: column; + align-items: stretch; + } +} diff --git a/src/static/admin/js/mostovik-admin-upload.js b/src/static/admin/js/mostovik-admin-upload.js new file mode 100644 index 0000000..c032147 --- /dev/null +++ b/src/static/admin/js/mostovik-admin-upload.js @@ -0,0 +1,33 @@ +document.addEventListener("DOMContentLoaded", () => { + const pickers = document.querySelectorAll("[data-file-picker]"); + + for (const picker of pickers) { + const input = picker.querySelector("[data-file-input]"); + const summary = picker.querySelector("[data-file-summary]"); + + if (!input || !summary) { + continue; + } + + const emptyText = summary.dataset.emptyText || "Файл не выбран"; + + const renderSummary = () => { + const files = Array.from(input.files || []); + + if (!files.length) { + summary.textContent = emptyText; + return; + } + + if (files.length === 1) { + summary.textContent = files[0].name; + return; + } + + summary.textContent = `Выбрано файлов: ${files.length}`; + }; + + input.addEventListener("change", renderSummary); + renderSummary(); + } +}); diff --git a/src/templates/admin/index.html b/src/templates/admin/index.html index 6ffc3a7..399ad14 100644 --- a/src/templates/admin/index.html +++ b/src/templates/admin/index.html @@ -1,10 +1,14 @@ {% extends "admin/base_site.html" %} -{% load i18n static jazzmin %} -{% get_jazzmin_ui_tweaks as jazzmin_ui %} +{% load i18n static jazzmin log %} -{% block bodyclass %}{{ block.super }} dashboard{% endblock %} +{% block bodyclass %}{{ block.super }} dashboard mx-dashboard-page{% endblock %} -{% block content_title %} {% trans 'Dashboard' %} {% endblock %} +{% block extrastyle %} + {{ block.super }} + +{% endblock %} + +{% block content_title %}{% trans 'Dashboard' %}{% endblock %} {% block breadcrumbs %}