From 5ef343729107d0dd48a5094ba6d4179f2ca31346 Mon Sep 17 00:00:00 2001 From: catborise Date: Sun, 25 Oct 2026 23:20:28 +0300 Subject: [PATCH] fix(admin): the Logs download survives any row and stops at a size it can build - A failed login writes the name it was tried with, as typed: a control character in it made every download holding that row fail, as the workbook cannot hold one. Workbooks now write it as \x01; the VM report shares the code. - A download held every matching row in memory: it now takes the newest 100000, and its About sheet says when the search should be narrowed. --- admin/tests/test_logs_page.py | 16 ++++++++++++++++ admin/views.py | 9 ++++++++- instances/report.py | 5 ++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/admin/tests/test_logs_page.py b/admin/tests/test_logs_page.py index f46a67c7..43c124bd 100644 --- a/admin/tests/test_logs_page.py +++ b/admin/tests/test_logs_page.py @@ -3,6 +3,7 @@ only, says the year and time zone, links a VM's rows to the VM, and downloads what it shows as an Excel workbook.""" from io import BytesIO +from unittest.mock import patch from computes.models import Compute from django.contrib.auth import get_user_model @@ -85,3 +86,18 @@ class LogsPageTests(TestCase): def test_only_an_administrator_downloads(self): self.client.force_login(get_user_model().objects.create_user("lg-user", password="pw")) self.assertEqual(self.client.get(reverse("admin:logs_export")).status_code, 403) + + def test_a_control_character_in_a_row_is_shown_not_a_failure(self): + # a failed login writes the name it was tried with, as typed + Logs.objects.create(user="eve\x01", instance="", message="Login", success=False) + response = self.client.get(reverse("admin:logs_export"), {"q": "eve"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(load_workbook(BytesIO(response.content)).active.cell(row=2, column=2).value, "eve\\x01") + + def test_the_download_stops_at_its_row_limit_and_says_so(self): + with patch("admin.views.EXPORT_ROWS", 3): + book = load_workbook(BytesIO(self.client.get(reverse("admin:logs_export")).content)) + self.assertEqual(book.active.max_row, 4) # the header and three rows + notes = [row[0] for row in book["About"].values] + self.assertIn("Only the newest 3 rows: narrow the search for the others", notes) + diff --git a/admin/views.py b/admin/views.py index 40f02d95..cf744e26 100644 --- a/admin/views.py +++ b/admin/views.py @@ -260,6 +260,10 @@ def user_unblock(request, pk): return redirect("admin:user_list") +# rows in one Logs download: the workbook is built in memory +EXPORT_ROWS = 100000 + + def _logs(request): """The Logs rows the search and the failed-only box choose, newest first, and the query that chooses them again.""" @@ -299,10 +303,13 @@ def logs_export(request): rows, text, failed, _query = _logs(request) zone = timezone.get_current_timezone_name() header = [f"Date ({zone})", "User", "Host", "VM", "VM UUID", "Message", "Result", "IP"] + logs = list(rows[:EXPORT_ROWS + 1]) data = [[timezone.localtime(log.date).strftime("%Y-%m-%d %H:%M:%S"), log.user, log.host, log.instance, - log.instance_uuid, log.message, str(_result(log.success)), log.ip or ""] for log in rows.iterator()] + log.instance_uuid, log.message, str(_result(log.success)), log.ip or ""] for log in logs[:EXPORT_ROWS]] notes = [f"Search: {text}" if text else "Search: none", "Failed only" if failed else "All results", f"Rows: {len(data)}", f"Times in {zone}"] + if len(logs) > EXPORT_ROWS: + notes.append(f"Only the newest {EXPORT_ROWS} rows: narrow the search for the others") response = HttpResponse(workbook("Logs", header, data, "About", notes), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") name = f"webvirtcloud-logs-{timezone.localtime():%Y%m%d-%H%M%S}.xlsx" diff --git a/instances/report.py b/instances/report.py index 64ac0f65..7a5e12b4 100644 --- a/instances/report.py +++ b/instances/report.py @@ -3,6 +3,7 @@ from io import BytesIO from openpyxl import Workbook +from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE from openpyxl.styles import Font from openpyxl.utils import get_column_letter @@ -17,7 +18,9 @@ def workbook(title, header, rows, notes_title, notes): sheet = book.active sheet.title = title for values in [header, *rows]: - sheet.append(values) + # a control character (a name typed at a failed login) cannot be in the file: written as \x01 + sheet.append([ILLEGAL_CHARACTERS_RE.sub(lambda m: f"\\x{ord(m.group()):02x}", value) + if isinstance(value, str) else value for value in values]) for row in sheet.iter_rows(): for cell in row: if isinstance(cell.value, str):