Загрузка данных
constants
STATUS_DESELECTED = "deselected"
STATUS_NO_DATA = "no_data"
ALLURE_RESULTS_ARCHIVE_SUFFIX = ".tar.gz"
# Gzip: сигнатура в начале файла (magic bytes) - первые байты, по которым
# распознаётся формат. Для gzip это 0x1F 0x8B.
GZIP_MAGIC_BYTE_1 = 0x1F
GZIP_MAGIC_BYTE_2 = 0x8B
GZIP_HEADER_SIZE = 2
GZIP_MAGIC = bytes([GZIP_MAGIC_BYTE_1, GZIP_MAGIC_BYTE_2])
helpers\archive_utils.py
"""
Валидация и безопасная распаковка tar.gz архивов allure-results.
"""
from __future__ import annotations
import io
import os
import tarfile
from dataclasses import dataclass
from typing import Optional, Sequence
import constants as const
from helpers.allure_utils import extract_stand_from_environment_file
class UploadValidationError(Exception):
"""Ошибка валидации загружаемых файлов до создания записей в БД."""
def __init__(self, message: str, code: str = "upload_validation_failed") -> None:
self.message = message
self.code = code
super().__init__(message)
@dataclass
class TarArchiveInfo:
"""Результат успешной валидации tar.gz архива."""
payload_file_count: int
detected_stand: Optional[str]
def is_gzip_payload(data: bytes) -> bool:
"""
Проверяет, что файл начинается с gzip-сигнатуры (magic bytes).
"""
if len(data) < const.GZIP_HEADER_SIZE:
return False
header = data[:const.GZIP_HEADER_SIZE]
return header == const.GZIP_MAGIC
def _unsafe_path_error_message(member_name: str) -> str:
return (
f"Архив не принят: внутри есть файл '{member_name}' с некорректным путём. "
"Соберите allure-results.tar.gz так, чтобы файлы лежали в одной папке "
"без выхода 'вверх' по директориям."
)
def _unsafe_link_error_message(member_name: str) -> str:
return (
f"Архив не принят: внутри есть ссылка '{member_name}'. "
"В архиве должны быть только обычные файлы allure-results, без ссылок."
)
def _unsafe_special_file_error_message(member_name: str) -> str:
return (
f"Архив не принят: внутри есть специальный файл '{member_name}'. "
"В архиве должны быть только обычные файлы allure-results."
)
def is_allure_payload_filename(name: str) -> bool:
"""Проверяет, что имя файла соответствует allure result/container."""
basename = os.path.basename(name.replace("\\", "/"))
return (
basename.endswith(const.RESULT_NAMING)
or basename.endswith(const.CONTAINER_NAMING)
)
def _normalize_member_path(path: str) -> str:
return path.replace("\\", "/").lstrip("/")
def _is_safe_member_path(destination_dir: str, member_name: str) -> bool:
normalized = _normalize_member_path(member_name)
if normalized.startswith("/") or normalized.startswith("../"):
return False
abs_destination = os.path.abspath(destination_dir)
member_target = os.path.abspath(os.path.join(destination_dir, normalized))
return member_target == abs_destination or member_target.startswith(
abs_destination + os.sep
)
def _reject_unsafe_tar_members(tar: tarfile.TarFile, destination_dir: str) -> None:
for member in tar.getmembers():
if not _is_safe_member_path(destination_dir, member.name):
raise UploadValidationError(
_unsafe_path_error_message(member.name),
code="unsafe_archive_path",
)
if member.issym() or member.islnk():
raise UploadValidationError(
_unsafe_link_error_message(member.name),
code="unsafe_archive_member",
)
if member.ischr() or member.isblk() or member.isfifo():
raise UploadValidationError(
_unsafe_special_file_error_message(member.name),
code="unsafe_archive_member",
)
def _collect_payload_paths(tar: tarfile.TarFile) -> list[str]:
payload_paths: list[str] = []
for member in tar.getmembers():
if not member.isfile():
continue
normalized = _normalize_member_path(member.name)
if is_allure_payload_filename(normalized):
payload_paths.append(normalized)
return payload_paths
def _validate_payload_directory_layout(payload_paths: Sequence[str]) -> None:
"""
Проверяет, что файлы allure-results можно однозначно сопоставить с одной директорией.
Логика соответствует _resolve_allure_results_dir в testrun_helpers.
"""
if not payload_paths:
raise UploadValidationError(
"Архив не содержит файлов allure-results "
"(*-result.json или *-container.json).",
code="missing_allure_results",
)
root_payload_files = [
path for path in payload_paths if "/" not in path and "\\" not in path
]
if root_payload_files:
nested_paths = [
path for path in payload_paths if "/" in path or "\\" in path
]
if nested_paths:
raise UploadValidationError(
"Архив содержит файлы allure-results в корне и во вложенных "
"директориях; невозможно однозначно определить источник отчёта.",
code="ambiguous_allure_results",
)
return
payload_directories: set[str] = set()
for path in payload_paths:
parent = os.path.dirname(path.replace("\\", "/"))
if parent:
payload_directories.add(parent)
if len(payload_directories) != 1:
raise UploadValidationError(
"Архив содержит файлы allure-results в нескольких директориях; "
"невозможно однозначно определить источник отчёта.",
code="ambiguous_allure_results",
)
def _extract_stand_from_tar(tar: tarfile.TarFile) -> Optional[str]:
for member in tar.getmembers():
if not member.isfile():
continue
if os.path.basename(member.name.replace("\\", "/")) != "environment.properties":
continue
fileobj = tar.extractfile(member)
if not fileobj:
continue
raw = fileobj.read()
stand = extract_stand_from_environment_file(raw)
if stand:
return stand.strip()
return None
def open_validated_tar_gz(archive_bytes: bytes) -> tarfile.TarFile:
"""
Проверяет, что байты - gzip+tar, и открывает tar для чтения.
Вызывающий код должен закрыть TarFile.
"""
if not archive_bytes:
raise UploadValidationError("Архив пустой.", code="empty_archive")
if not is_gzip_payload(archive_bytes):
raise UploadValidationError(
"Файл не является корректным gzip-архивом (ожидается tar.gz).",
code="invalid_gzip",
)
try:
tar = tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:gz")
except tarfile.TarError as error:
raise UploadValidationError(
f"Архив не является корректным tar.gz: {error}",
code="invalid_tar_gz",
) from error
try:
members = tar.getmembers()
if not members:
tar.close()
raise UploadValidationError(
"Архив tar.gz не содержит файлов.",
code="empty_tar_archive",
)
except tarfile.TarError as error:
tar.close()
raise UploadValidationError(
f"Не удалось прочитать содержимое tar.gz: {error}",
code="invalid_tar_gz",
) from error
return tar
def validate_tar_gz_archive(archive_bytes: bytes) -> TarArchiveInfo:
"""Полная валидация архива allure-results до записи в БД/MinIO."""
tar = open_validated_tar_gz(archive_bytes)
try:
validation_root = os.path.abspath(os.path.join(os.getcwd(), ".archive_validation"))
_reject_unsafe_tar_members(tar, validation_root)
payload_paths = _collect_payload_paths(tar)
_validate_payload_directory_layout(payload_paths)
detected_stand = _extract_stand_from_tar(tar)
return TarArchiveInfo(
payload_file_count=len(payload_paths),
detected_stand=detected_stand,
)
finally:
tar.close()
def safe_extract_tar_gz_bytes(archive_bytes: bytes, destination_dir: str) -> None:
"""
Безопасно распаковывает tar.gz в destination_dir.
Предполагается, что архив уже прошёл validate_tar_gz_archive.
"""
tar = open_validated_tar_gz(archive_bytes)
try:
os.makedirs(destination_dir, exist_ok=True)
_reject_unsafe_tar_members(tar, destination_dir)
extract_filter = getattr(tarfile, "data_filter", None)
if extract_filter is not None:
tar.extractall(destination_dir, filter="data")
return
for member in tar.getmembers():
if not member.isfile():
continue
if not _is_safe_member_path(destination_dir, member.name):
raise UploadValidationError(
_unsafe_path_error_message(member.name),
code="unsafe_archive_path",
)
tar.extract(member, path=destination_dir)
finally:
tar.close()
helpers\testrun_helpers.py
import datetime
import io
import json
import os
import shutil
import subprocess
import tempfile
from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple
from zoneinfo import ZoneInfo
import flask
from flask import abort
from minio import S3Error
from sqlalchemy import inspect
from sqlalchemy.exc import DatabaseError
from werkzeug.datastructures import FileStorage
import constants as const
from app import db
from app.clients import MinioClient
from app.models import TestResult
from helpers.allure_utils import extract_stand_from_environment_file
from helpers.archive_utils import (
UploadValidationError,
is_allure_payload_filename,
open_validated_tar_gz,
safe_extract_tar_gz_bytes,
validate_tar_gz_archive,
)
from logger import init_logger
RunStatusSignal = Literal["none", "fail", "broken"]
minio_client = MinioClient()
logger = init_logger()
def validate_upload_files(files: Sequence[FileStorage]) -> None:
"""
Валидирует загружаемые файлы до создания записей в БД и загрузки в MinIO.
"""
archive_filenames: list[str] = []
loose_payload_count = 0
for file in files:
filename = file.filename or ""
if not allowed_file(filename):
raise UploadValidationError(
f"Недопустимый файл: {filename}",
code="invalid_file_type",
)
file_content = _read_file_content(file)
file.seek(0)
if _is_allure_results_archive(filename):
validate_tar_gz_archive(file_content)
archive_filenames.append(filename)
elif is_allure_payload_filename(filename):
loose_payload_count += 1
if archive_filenames:
if len(archive_filenames) > 1:
raise UploadValidationError(
"Ожидается один архив allure-results.tar.gz.",
code="multiple_archives",
)
if len(files) > len(archive_filenames):
raise UploadValidationError(
"Смешанная загрузка архива и отдельных файлов не поддерживается.",
code="mixed_upload",
)
return
if loose_payload_count == 0:
raise UploadValidationError(
"Не найдены файлы allure-results (*-result.json или *-container.json).",
code="missing_allure_results",
)
def get_request_files() -> List[FileStorage]:
"""Возвращает список валидных файлов из запроса."""
files = flask.request.files.getlist("files")
valid_files = [file for file in files if file and file.filename]
if not valid_files:
logger.error("Необходимо загрузить хотя бы один файл")
flask.abort(400, description="Необходимо загрузить хотя бы один файл")
return valid_files
def create_temp_test_result() -> TestResult:
"""Создает временную запись TestResult или завершает запрос с ошибкой."""
try:
new_result = create_temporary_test_result()
logger.info("Создана новая временная запись о запуске автотестов")
return new_result
except DatabaseError as error_msg:
db.session.rollback()
logger.exception("Ошибка при создании записи в базе данных")
flask.abort(500, description=str(error_msg))
def extract_test_run_info(files: Sequence[FileStorage]):
"""Анализирует файлы и возвращает информацию о тестране."""
try:
test_run_info = check_all_tests_passed_run(files)
if not test_run_info:
logger.error("Не удалось извлечь параметры тестрана")
flask.abort(400, description="Ошибка анализа файлов тестрана")
return test_run_info
except Exception as error_msg:
logger.exception("Неизвестная ошибка при анализе тестрана")
flask.abort(500, description=str(error_msg))
def upload_all_files(
run_name: str, files: Sequence[FileStorage]
) -> Tuple[List[str], List[str]]:
"""Загружает файлы и разделяет их на успешные/ошибочные."""
success_files: List[str] = []
error_files: List[str] = []
minio_client.ensure_bucket_exists(const.ALLURE_RESULTS_BUCKET_NAME)
for file in files:
filename = file.filename or "unknown"
if not allowed_file(filename):
logger.error("Недопустимый файл: %s", filename)
error_files.append(filename)
continue
try:
uploaded = process_and_upload_file(run_name, file)
success_files.append(uploaded)
except (DatabaseError, OSError, ValueError) as file_error:
logger.exception("Ошибка обработки файла %s: %s", filename, file_error)
db.session.rollback()
error_files.append(filename)
return success_files, error_files
def get_existing_run_or_abort(result_id: int) -> TestResult:
"""Возвращает TestResult или завершает запрос, если запись недоступна."""
testrun = TestResult.query.get(result_id)
log_and_abort(result_id, testrun)
return testrun
def _validate_upload_file(file: FileStorage) -> str:
"""Убедиться, что объект файла пригоден для дальнейшей обработки."""
if not file or not file.filename:
logger.error("Загружен пустой или поврежденный файл")
raise ValueError("Файл отсутствует или поврежден.")
return file.filename
def _read_file_content(file: FileStorage) -> bytes:
"""Считывает и валидирует содержимое файла."""
file.seek(0)
content = file.read()
if not content:
raise ValueError(f"Файл {file.filename} пустой и не будет загружен.")
return content
def _extract_stand_value(filename: str, file_content: bytes) -> Optional[str]:
"""Пытается извлечь stand из environment.properties."""
if filename != "environment.properties":
return None
try:
content_text = file_content.decode("utf-8", errors="ignore")
except Exception:
logger.exception(
"Не удалось декодировать environment.properties для извлечения stand"
)
return None
stand = extract_stand_from_environment_file(content_text) or None
return stand.strip() if stand else None
def _is_allure_results_archive(filename: str) -> bool:
"""Проверяет, что загружен архив allure-results.tar.gz."""
return filename.lower().endswith(const.ALLURE_RESULTS_ARCHIVE_SUFFIX)
def _extract_stand_from_archive(archive_bytes: bytes) -> Optional[str]:
"""Извлекает stand из environment.properties внутри tar.gz архива."""
tar = open_validated_tar_gz(archive_bytes)
try:
for member in tar.getmembers():
if not member.isfile():
continue
if os.path.basename(member.name.replace("\\", "/")) != "environment.properties":
continue
fileobj = tar.extractfile(member)
if not fileobj:
continue
raw = fileobj.read()
return _extract_stand_value("environment.properties", raw)
finally:
tar.close()
return None
def _persist_detected_stand(run_name: str, detected_stand: str) -> None:
"""Сохраняет значение stand в TestResult, если запись существует."""
try:
test_result: Optional[TestResult] = TestResult.query.filter_by(
run_name=run_name, is_deleted=False
).first()
if not test_result:
logger.warning(
"Не удалось найти TestResult для run_name=%s, stand=%s не сохранён",
run_name,
detected_stand,
)
return
test_result.stand = detected_stand
db.session.add(test_result)
db.session.commit()
logger.info(
"Сохранили stand='%s' для run=%s в TestResult(id=%s)",
detected_stand,
run_name,
test_result.id,
)
except Exception:
logger.exception(
"Ошибка при сохранении stand=%s для run=%s в базе данных",
detected_stand,
run_name,
)
def _upload_file_to_minio(run_name: str, filename: str, file_content: bytes) -> None:
"""Загружает файл в MinIO."""
file_path = f"{run_name}/{filename}"
file_stream = io.BytesIO(file_content)
minio_client.ensure_bucket_exists(const.ALLURE_RESULTS_BUCKET_NAME)
minio_client.put_object(
bucket_name=const.ALLURE_RESULTS_BUCKET_NAME,
file_path=file_path,
file_stream=file_stream,
content_length=len(file_content),
)
def _read_allure_json_from_archive(file: FileStorage) -> list[tuple[str, dict]]:
"""
Считывает JSON-объекты result/container из tar.gz для анализа статуса/времени.
"""
file.seek(0)
archive_bytes = file.read()
if not archive_bytes:
raise ValueError("Архив пустой.")
collected: list[tuple[str, dict]] = []
tar = open_validated_tar_gz(archive_bytes)
try:
for member in tar.getmembers():
if not member.isfile():
continue
basename = os.path.basename(member.name.replace("\\", "/"))
if not (
basename.endswith(const.RESULT_NAMING)
or basename.endswith(const.CONTAINER_NAMING)
):
continue
fileobj = tar.extractfile(member)
if not fileobj:
continue
raw = fileobj.read()
try:
parsed = json.loads(raw.decode(const.ENCODING))
except (UnicodeDecodeError, json.JSONDecodeError):
logger.warning("Некорректный JSON в архиве: %s", member.name)
collected.append((basename, {}))
continue
collected.append((basename, parsed))
finally:
tar.close()
return collected
def _safe_int(value: Optional[int]) -> Optional[int]:
"""Безопасно преобразует значение к int."""
try:
return int(value) if value is not None else None
except (TypeError, ValueError):
return None
def _normalize_status_value(status: Optional[str]) -> Optional[str]:
if status is None:
return None
normalized = str(status).strip().lower()
return normalized or None
def _status_signal_from_value(status: Optional[str]) -> RunStatusSignal:
"""
Возвращает значимость статуса для итогового прогона:
broken > fail > none (passed/skipped/отсутствует).
"""
normalized = _normalize_status_value(status)
if normalized is None:
return "none"
if normalized == const.STATUS_BROKEN:
return const.STATUS_BROKEN
if normalized in {const.STATUS_FAIL, "failed"}:
return const.STATUS_FAIL
if normalized in {
const.STATUS_PASS,
const.STATUS_SKIPPED,
const.STATUS_DESELECTED,
}:
return "none"
# Неизвестные статусы считаем неуспешными, чтобы не показывать ложный "passed".
return const.STATUS_FAIL
def _merge_status_signals(
current: RunStatusSignal, candidate: RunStatusSignal
) -> RunStatusSignal:
if current == const.STATUS_BROKEN or candidate == const.STATUS_BROKEN:
return const.STATUS_BROKEN
if current == const.STATUS_FAIL or candidate == const.STATUS_FAIL:
return const.STATUS_FAIL
return "none"
def _collect_steps_status_signal(steps: Optional[Sequence[dict]]) -> RunStatusSignal:
"""Рекурсивно собирает самый приоритетный статус из шагов."""
if not steps:
return "none"
signal: RunStatusSignal = "none"
for step in steps:
signal = _merge_status_signals(
signal, _status_signal_from_value(step.get(const.STATUS_KEY))
)
signal = _merge_status_signals(
signal, _collect_steps_status_signal(step.get("steps"))
)
if signal == const.STATUS_BROKEN:
return signal
return signal
def _collect_result_status_signal(data: dict) -> RunStatusSignal:
"""Собирает итоговый сигнал статуса из теста, его шагов, befores и afters."""
signal = _status_signal_from_value(data.get(const.STATUS_KEY))
signal = _merge_status_signals(
signal, _collect_steps_status_signal(data.get("steps"))
)
for section in ("befores", "afters"):
for entry in data.get(section, []):
signal = _merge_status_signals(
signal, _status_signal_from_value(entry.get(const.STATUS_KEY))
)
signal = _merge_status_signals(
signal, _collect_steps_status_signal(entry.get("steps"))
)
if signal == const.STATUS_BROKEN:
return signal
return signal
def _run_status_from_signal(signal: RunStatusSignal) -> str:
if signal == const.STATUS_BROKEN:
return const.STATUS_BROKEN
if signal == const.STATUS_FAIL:
return const.STATUS_FAIL
return const.STATUS_PASS
def allowed_file(filename: str) -> bool:
"""
По точке находим расширение файла и проверяем на соответствие списка разрешенных в ALLOWED_EXTENSIONS
"""
if _is_allure_results_archive(filename):
return True
return (
"." in filename
and filename.rsplit(".", 1)[1].lower() in const.ALLOWED_EXTENSIONS
)
def process_and_upload_file(run_name: str, file: FileStorage) -> str:
"""
Валидирует, обрабатывает и загружает файл в MinIO.
Пытается извлечь stand из environment.properties и сохранить его в БД.
"""
try:
filename = _validate_upload_file(file)
logger.info("Тип файла: %s, имя файла: %s", type(file), filename)
file_content = _read_file_content(file)
logger.info("Размер файла %s: %s байт", filename, len(file_content))
if _is_allure_results_archive(filename):
detected_stand = _extract_stand_from_archive(file_content)
_upload_file_to_minio(
run_name, const.ALLURE_RESULTS_ARCHIVE_NAME, file_content
)
else:
detected_stand = _extract_stand_value(filename, file_content)
_upload_file_to_minio(run_name, filename, file_content)
if detected_stand:
logger.info(
"Обнаружен stand='%s' в environment.properties для run=%s",
detected_stand,
run_name,
)
_persist_detected_stand(run_name, detected_stand)
return filename
except OSError:
logger.exception("Ошибка обработки файла", filename=file.filename)
raise
def parse_json_file(file: Any) -> Optional[dict]:
"""Парсит содержимое файла и возвращает json данные."""
try:
content = file.read().decode(const.ENCODING)
return json.loads(content)
except json.JSONDecodeError:
logger.exception("Ошибка при чтении файла", filename=file.filename)
return None
def format_timestamp(timestamp: int) -> str:
"""Форматирует временную метку в миллисекундах в строку по заданному формату."""
return datetime.datetime.fromtimestamp(
timestamp / const.TIMESTAMP_DIVISOR
).strftime(const.DB_DATE_FORMAT)
def check_all_tests_passed_run(
files: Sequence[FileStorage],
) -> dict[str, Optional[str]]:
"""
Проверяет, прошли ли все автотесты успешно, и возвращает статус,
а также время начала и окончания выполнения тестов.
Метод анализирует список файлов, содержащих результаты выполнения автотестов,
и определяет общий статус тестирования по приоритету:
'broken' > 'fail' > 'passed'.
Статус 'skipped' не влияет на итоговый статус тестрана.
Время начала и окончания тестов определяется либо из контейнерного файла,
если он присутствует, либо из файлов с результатами, если контейнерный файл отсутствует.
Параметры:
files (list): Список файлов, содержащих результаты выполнения автотестов.
Каждый элемент списка должен иметь атрибут 'filename', который
используется для идентификации типа файла.
Возвращает:
dict: Словарь с ключами и значениями:
- const.STATUS_KEY: Статус выполнения тестов
('passed', 'fail', 'broken' или 'skipped').
- const.START_RUN_KEY: Время начала выполнения тестов в строковом формате
(или None, если время не определено).
- const.STOP_RUN_KEY: Время окончания выполнения тестов в строковом формате
(или None, если время не определено).
Обработка:
- Инициализирует переменные для статуса тестов и времени начала/окончания.
- Перебирает файлы и анализирует их содержимое.
- Если файл является результатом выполнения тестов, проверяет статус и извлекает
временные метки.
- Если файл является контейнером, извлекает время начала и окончания.
- В случае отсутствия контейнера, использует минимальное и максимальное время
из файлов с результатами.
- Конвертирует временные метки в строковый формат.
- Возвращает словарь с итоговыми данными.
"""
status_signal: RunStatusSignal = "none"
has_non_skipped_status = False
has_skipped_like_status = False
has_payload_entries = False
has_valid_payload_data = False
logger.info("Проверка статусов автотестов внутри данного отчета")
result_start_times: list[int] = []
result_stop_times: list[int] = []
container_start_times: list[int] = []
container_stop_times: list[int] = []
container_start_ms: Optional[int] = None
container_stop_ms: Optional[int] = None
for file in files:
filename = getattr(file, "filename", "") or ""
if _is_allure_results_archive(filename):
archive_entries = _read_allure_json_from_archive(file)
if archive_entries:
has_payload_entries = True
for entry_name, data in archive_entries:
if not data:
status_signal = _merge_status_signals(status_signal, const.STATUS_FAIL)
continue
has_valid_payload_data = True
if entry_name.endswith(const.RESULT_NAMING):
# Итоговый статус прогона считаем только по статусам тест-кейсов.
file_status_signal = _status_signal_from_value(
data.get(const.STATUS_KEY)
)
status_signal = _merge_status_signals(
status_signal, file_status_signal
)
test_status = _normalize_status_value(data.get(const.STATUS_KEY))
if test_status in {const.STATUS_SKIPPED, const.STATUS_DESELECTED}:
has_skipped_like_status = True
elif test_status is not None:
has_non_skipped_status = True
start_ms = _safe_int(data.get(const.START_RUN_KEY))
stop_ms = _safe_int(data.get(const.STOP_RUN_KEY))
if start_ms is not None:
result_start_times.append(start_ms)
if stop_ms is not None:
result_stop_times.append(stop_ms)
elif entry_name.endswith(const.CONTAINER_NAMING):
container_start = _safe_int(data.get(const.START_RUN_KEY))
container_stop = _safe_int(data.get(const.STOP_RUN_KEY))
if container_start is not None:
container_start_times.append(container_start)
if container_stop is not None:
container_stop_times.append(container_stop)
elif filename.endswith(const.RESULT_NAMING):
has_payload_entries = True
data = parse_json_file(file)
if not data:
status_signal = _merge_status_signals(status_signal, const.STATUS_FAIL)
logger.warning("Файл %s не содержит валидный JSON", filename)
else:
has_valid_payload_data = True
file_status_signal = _status_signal_from_value(
data.get(const.STATUS_KEY)
)
status_signal = _merge_status_signals(status_signal, file_status_signal)
if file_status_signal != "none":
logger.info(
"В файле %s обнаружен неуспешный статус теста", filename
)
test_status = _normalize_status_value(data.get(const.STATUS_KEY))
if test_status in {const.STATUS_SKIPPED, const.STATUS_DESELECTED}:
has_skipped_like_status = True
elif test_status is not None:
has_non_skipped_status = True
start_ms = _safe_int(data.get(const.START_RUN_KEY))
stop_ms = _safe_int(data.get(const.STOP_RUN_KEY))
if start_ms is not None:
result_start_times.append(start_ms)
if stop_ms is not None:
result_stop_times.append(stop_ms)
elif filename.endswith(const.CONTAINER_NAMING):
has_payload_entries = True
data = parse_json_file(file)
if data:
has_valid_payload_data = True
container_start = _safe_int(data.get(const.START_RUN_KEY))
container_stop = _safe_int(data.get(const.STOP_RUN_KEY))
if container_start is not None:
container_start_times.append(container_start)
if container_stop is not None:
container_stop_times.append(container_stop)
if container_start_times:
container_start_ms = min(container_start_times)
if container_stop_times:
container_stop_ms = max(container_stop_times)
if container_start_ms is None and result_start_times:
container_start_ms = min(result_start_times)
if container_stop_ms is None and result_stop_times:
container_stop_ms = max(result_stop_times)
start_time_str = (
format_timestamp(container_start_ms) if container_start_ms else None
)
stop_time_str = format_timestamp(container_stop_ms) if container_stop_ms else None
if not has_payload_entries or not has_valid_payload_data:
if status_signal == "none":
status = const.STATUS_NO_DATA
elif (
status_signal == "none"
and has_skipped_like_status
and not has_non_skipped_status
):
status = const.STATUS_SKIPPED
else:
status = _run_status_from_signal(status_signal)
logger.info(
"Итоговый статус тестов: %s, start=%s, stop=%s",
status,
start_time_str,
stop_time_str,
)
return {
const.STATUS_KEY: status,
const.START_RUN_KEY: start_time_str,
const.STOP_RUN_KEY: stop_time_str,
}
def create_temporary_test_result():
"""
Создает временную запись тестового результата в базе данных, при необходимости создавая соответствующую таблицу.
Возвращает:
- Объект `TestResult`, представляющий созданную запись о тестовом результате.
Поведение:
- Проверяет наличие таблицы `TestResult`. Если она не существует, создает её.
- Создает новую запись `TestResult` с параметрами по умолчанию:
- `run_name`: имя запуска формируется на основе константы `DEFAULT_RUN_NAME` и текущего времени.
- `start_date`: `None`, так как результаты теста еще не распарсили.
- `end_date`: `None`, так как результаты теста еще не распарсили.
- `status`: устанавливается в `PENDING_STATUS`, показывая, что еще нет результата.
- `file_link`: `None` изначально, поскольку ссылка на файл результата задается позже.
- Добавляет новую запись в сеанс базы данных и сохраняет изменения.
Исключения:
- В случае ошибки базы данных, выполняет откат текущего сеанса и логирует ошибку,
затем повторно вызывает исключение `DatabaseError`.
"""
try:
# Создаем таблицу, если она еще не создана
inspector = inspect(db.engine)
if not inspector.has_table(TestResult.__tablename__):
db.create_all()
# Создаем новый тестовый результат
new_result = TestResult(
run_name=f"{const.DEFAULT_RUN_NAME}_{datetime.datetime.now()}",
start_date=None,
end_date=None,
status=const.PENDING_STATUS,
)
# Добавляем и коммитим новую запись
db.session.add(new_result)
db.session.commit()
return new_result
except DatabaseError as error_msg:
# Обработка ошибки базы данных
db.session.rollback()
logger.exception("Ошибка при создании записи в базе данных", exc_info=error_msg)
raise
def update_test_result(new_result: "TestResult", test_run_info: dict) -> None:
"""Обновляет параметры тестового запуска в БД."""
run_id = new_result.id
run_name = f"run_{run_id}_{test_run_info.get(const.START_RUN_KEY)}"
new_result.run_name = run_name
new_result.status = test_run_info.get(const.STATUS_KEY)
new_result.start_date = test_run_info.get(const.START_RUN_KEY)
new_result.end_date = test_run_info.get(const.STOP_RUN_KEY)
db.session.commit()
def check_files_size(files: list, max_size: int = None) -> bool:
"""
Проверка размера загружаемых файлов.
files - список файлов для проверки.
max_size - максимальный допустимый размер в байтах.
"""
# Получение размера по умолчанию из переменной или установки стандартного 50 МБ
if max_size is None:
max_size = const.MAX_FILE_SIZE # 50 MB по умолчанию
# Считаем общий размер файлов
total_size = 0
for file in files:
file.seek(0, 2) # Переместить курсор в конец файла
total_size += file.tell() # Получить текущую позицию (размер в байтах)
file.seek(0) # Сбросить курсор на начало
# Проверка превышения лимита
if total_size > max_size:
logger.error(
"Общий размер загружаемых файлов превышен",
total_size=total_size,
max_size=max_size,
)
abort(400, description="Общий размер загружаемых файлов превышает допустимый")
logger.info(
"Размер файлов в пределах допустимого", total_size=total_size, max_size=max_size
)
return True
def _format_datetime(value: Optional[datetime.datetime]) -> Optional[str]:
if not value:
return None
try:
# Считаем входные даты в UTC, приводим к часовому поясу Москвы
moscow_tz = ZoneInfo("Europe/Moscow")
if value.tzinfo is None:
value = value.replace(tzinfo=datetime.timezone.utc)
value = value.astimezone(moscow_tz)
except Exception:
# Если что-то пошло не так, возвращаем как есть
pass
return value.strftime(const.VIEW_DATE_FORMAT)
def _serialize_test_result(result: TestResult) -> Dict[str, Any]:
"""Приводит TestResult к словарю для фронтенда."""
return {
"id": result.id,
"run_name": result.run_name,
"start_date": _format_datetime(result.start_date),
"end_date": _format_datetime(result.end_date),
"stand": result.stand or None,
"status": result.status,
}
def extract_filter_values(param_name: str) -> List[str]:
"""
Возвращает очищенный список значений фильтра из query string.
Удаляет прочерки и пустые значения, поддерживает нотации name и name[].
"""
values = flask.request.args.getlist(param_name)
if not values:
values = flask.request.args.getlist(f"{param_name}[]")
if len(values) == 1 and "," in (values[0] or ""):
values = [chunk.strip() for chunk in values[0].split(",")]
cleaned: List[str] = []
seen = set()
for value in values:
if value is None:
continue
candidate = value.strip()
if not candidate or candidate == "-":
continue
if candidate in seen:
continue
seen.add(candidate)
cleaned.append(candidate)
return cleaned
def _normalize_filter_values(values: Optional[Sequence[str]]) -> List[str]:
"""Подготавливает список значений для безопасного использования в запросах."""
if not values:
return []
normalized: List[str] = []
seen = set()
for value in values:
if value is None:
continue
cleaned = str(value).strip()
if not cleaned or cleaned == "-":
continue
if cleaned in seen:
continue
seen.add(cleaned)
normalized.append(cleaned)
return normalized
def _collect_distinct_column_values(column) -> List[str]:
"""Возвращает уникальные значения столбца для не удалённых записей."""
rows = (
db.session.query(column)
.filter(
TestResult.is_deleted.is_(False),
column.isnot(None),
column != "",
column != "-",
)
.distinct()
.order_by(column.asc())
.all()
)
values: List[str] = []
seen = set()
for (value,) in rows:
if value is None:
continue
cleaned = value.strip() if isinstance(value, str) else value
if not cleaned or cleaned == "-":
continue
if cleaned in seen:
continue
seen.add(cleaned)
values.append(cleaned)
return values
def _get_available_report_filters() -> Dict[str, List[str]]:
"""Формирует справочник доступных фильтров для отчётов."""
return {
"statuses": _collect_distinct_column_values(TestResult.status),
"stands": _collect_distinct_column_values(TestResult.stand),
}
def _has_older_runs(oldest_id: int) -> bool:
"""Проверяет наличие более старых записей по id."""
return (
TestResult.query.filter(
TestResult.is_deleted.is_(False), TestResult.id < oldest_id
)
.order_by(TestResult.id.desc())
.limit(1)
.first()
is not None
)
def _has_newer_runs(newest_id: int) -> bool:
"""Проверяет наличие более новых записей по id."""
return (
TestResult.query.filter(
TestResult.is_deleted.is_(False), TestResult.id > newest_id
)
.order_by(TestResult.id.asc())
.limit(1)
.first()
is not None
)
def _parse_date_param(
value: Optional[str], end_of_day: bool = False
) -> Optional[datetime.datetime]:
"""
Парсит строку даты в формате YYYY-MM-DD в datetime.
Если end_of_day=True, устанавливает время 23:59:59, иначе 00:00:00.
"""
if not value:
return None
try:
date = datetime.datetime.strptime(value.strip(), "%Y-%m-%d")
if end_of_day:
date = date.replace(hour=23, minute=59, second=59)
return date
except ValueError:
logger.warning(f"Некорректный формат даты: {value}")
return None
def fetch_reports(
cursor: Optional[int],
limit: int,
direction: str = "next",
statuses: Optional[Sequence[str]] = None,
stands: Optional[Sequence[str]] = None,
start_date_from: Optional[str] = None,
start_date_to: Optional[str] = None,
) -> Dict[str, Any]:
"""
Возвращает страницу отчетов с курсорной пагинацией.
direction: 'next' (старее) или 'prev' (новее).
statuses/stands — списки значений для фильтрации (множество значений).
start_date_from/start_date_to — фильтр по дате старта (формат YYYY-MM-DD).
"""
if direction not in {"next", "prev"}:
logger.error(f"Получено некорректное направление пагинации: {direction}")
raise ValueError("Направление должно быть либо 'next' или 'prev'")
# Парсим даты
date_from = _parse_date_param(start_date_from, end_of_day=False)
date_to = _parse_date_param(start_date_to, end_of_day=True)
# Валидация: дата "с" должна быть меньше или равна дате "до"
if date_from and date_to and date_from > date_to:
raise ValueError("Дата 'с' должна быть меньше или равна дате 'до'")
available_filters = _get_available_report_filters()
normalized_statuses = _normalize_filter_values(statuses)
normalized_stands = _normalize_filter_values(stands)
base_query = TestResult.query.filter_by(is_deleted=False)
if cursor:
if direction == "next":
base_query = base_query.filter(TestResult.id < cursor)
else:
base_query = base_query.filter(TestResult.id > cursor)
if normalized_statuses:
base_query = base_query.filter(TestResult.status.in_(normalized_statuses))
if normalized_stands:
base_query = base_query.filter(TestResult.stand.in_(normalized_stands))
# Фильтр по дате старта
if date_from:
base_query = base_query.filter(TestResult.start_date >= date_from)
if date_to:
base_query = base_query.filter(TestResult.start_date <= date_to)
order_column = TestResult.id.desc()
if direction == "prev":
order_column = TestResult.id.asc()
results = base_query.order_by(order_column).limit(limit + 1).all()
has_more_in_direction = len(results) > limit
items = results[:limit]
if direction == "prev":
items = list(reversed(items))
serialized = [_serialize_test_result(item) for item in items]
if not items:
return {
"items": [],
"next_cursor": None,
"prev_cursor": None,
"has_next": False,
"has_prev": False,
"filters": available_filters,
}
newest_id = items[0].id
oldest_id = items[-1].id
has_prev = _has_newer_runs(newest_id)
has_next = _has_older_runs(oldest_id)
# Обновляем флаги, учитывая результат текущего запроса
if direction == "next" and cursor:
has_prev = True
if direction == "prev":
has_next = True
if not has_more_in_direction:
has_prev = False
return {
"items": serialized,
"next_cursor": oldest_id if has_next else None,
"prev_cursor": newest_id if has_prev else None,
"has_next": has_next,
"has_prev": has_prev,
"filters": available_filters,
}
def log_reports(results_present: bool) -> None:
"""Логирует состояние страницы отчётов."""
if results_present:
logger.info("Обработан запрос на страницу списка отчетов", status_code=200)
else:
logger.info(
"Обработан запрос на страницу списка отчетов, список отчетов пуст",
status_code=200,
)
def generate_and_upload_report(run_name: str) -> None:
"""
Генерирует и загружает allure-report в MinIO.
Аргумент run_name - название тест-рана, используемое для директории allure-result и allure-report.
"""
temp_dir = tempfile.mkdtemp() # Создаем временную директорию для результатов
report_dir = tempfile.mkdtemp() # Создаем временную директорию для отчёта
try:
logger.info("Начало скачивания файлов из MinIO")
download_allure_results(run_name, temp_dir)
results_dir_for_generation = _resolve_allure_results_dir(temp_dir)
logger.info("Начало генерации allure-report")
generate_allure_report(results_dir_for_generation, report_dir)
logger.info("Загрузка allure-report в MinIO")
upload_report_to_minio(run_name, report_dir)
finally:
logger.info("Очистка временных директорий")
cleanup_temporary_directories([temp_dir, report_dir])
def _resolve_allure_results_dir(base_dir: str) -> str:
"""
Находит директорию с allure results.
"""
def _is_allure_payload_name(name: str) -> bool:
return name.endswith(const.RESULT_NAMING) or name.endswith(
const.CONTAINER_NAMING
)
base_result_files = [
name
for name in os.listdir(base_dir)
if os.path.isfile(os.path.join(base_dir, name))
and _is_allure_payload_name(name)
]
if base_result_files:
return base_dir
directories_with_results: set[str] = set()
for root, _, files in os.walk(base_dir):
for name in files:
if _is_allure_payload_name(name):
directories_with_results.add(root)
break
if len(directories_with_results) == 1:
resolved_dir = next(iter(directories_with_results))
logger.info(
"Определена вложенная директория allure-results для генерации",
base_dir=base_dir,
resolved_dir=resolved_dir,
)
return resolved_dir
if not directories_with_results:
raise RuntimeError(
"После распаковки архива не найдены файлы результатов Allure "
"(*-result.json или *-container.json)."
)
raise RuntimeError(
"После распаковки обнаружено несколько директорий с файлами Allure "
"(*-result.json/*-container.json); "
"невозможно однозначно выбрать источник для генерации отчета."
)
def download_allure_results(
allure_results_directory: str, destination_dir: str
) -> None:
"""
Загружает результаты Allure из MinIO в указанную директорию.
Аргументы:
allure_results_directory - директория в MinIO с allure-results.
destination_dir - путь к локальной директории для сохранения allure-results.
"""
archive_object_name = (
f"{allure_results_directory}/{const.ALLURE_RESULTS_ARCHIVE_NAME}"
)
# Формат хранения: один tar.gz на прогон.
try:
minio_client.stat_object(const.ALLURE_RESULTS_BUCKET_NAME, archive_object_name)
archive_local_path = os.path.join(
destination_dir, const.ALLURE_RESULTS_ARCHIVE_NAME
)
minio_client.download_file(
const.ALLURE_RESULTS_BUCKET_NAME, archive_object_name, archive_local_path
)
with open(archive_local_path, "rb") as archive_file:
safe_extract_tar_gz_bytes(archive_file.read(), destination_dir)
try:
os.remove(archive_local_path)
except OSError:
logger.warning("Не удалось удалить временный архив %s", archive_local_path)
logger.info("Архив allure-results распакован", object=archive_object_name)
return
except S3Error:
logger.info(
"Архив allure-results.tar.gz не найден, fallback на legacy-структуру",
run_name=allure_results_directory,
)
# Legacy fallback: старые прогоны с набором отдельных файлов.
for obj in minio_client.list_objects(
const.ALLURE_RESULTS_BUCKET_NAME, prefix=f"{allure_results_directory}/"
):
file_path = os.path.join(destination_dir, obj.object_name.split("/")[-1])
minio_client.download_file(
const.ALLURE_RESULTS_BUCKET_NAME, obj.object_name, file_path
)
if os.path.exists(file_path):
logger.info(
f"Файл {file_path} загружен, размер: {os.path.getsize(file_path)} байт"
)
else:
logger.error(f"Ошибка: Файл {file_path} не загружен")
def generate_allure_report(result_dir_path: str, report_dir_path: str) -> None:
"""
Генерирует Allure-отчёт на основе результатов тестов.
На основе предоставленной директории с результатами тестов, функция выполняет
системную команду для генерации HTML-отчёта в указанной директории. В случае
ошибки генерации, логируется сообщение об ошибке и выбрасывается исключение.
result_dir_path - путь к директории, содержащей результаты тестов allure-results.
report_dir_path - путь к директории, куда будет сохранен HTML-отчёт Allure
"""
command = (
f"allure generate {result_dir_path} "
f"-o {report_dir_path} --clean --single-file"
)
try:
subprocess.run(command, shell=True, text=True, check=True)
except subprocess.CalledProcessError as error:
error_msg = (
"Нет вывода ошибки выполнения команды"
if not error.stderr
else error.stderr.strip()
)
logger.exception(
"Ошибка при генерации Allure-отчета",
description=error_msg,
error_code=error.returncode,
)
raise RuntimeError("Не удалось сгенерировать Allure-отчёт") from error
def upload_report_to_minio(run_name: str, report_dir: str) -> None:
"""
Загружает HTML-отчёт Allure в MinIO.
run_name - ммя тест-запуска для файла allure-report.
report_dir - путь к временной директории, где находится HTML-отчёт.
"""
final_report_file = os.path.join(report_dir, "index.html")
with open(final_report_file, "rb") as file:
minio_client.ensure_bucket_exists(const.ALLURE_REPORTS_BUCKET_NAME)
minio_client.put_object(
const.ALLURE_REPORTS_BUCKET_NAME,
f"{run_name}.html",
file,
os.path.getsize(final_report_file),
)
def cleanup_temporary_directories(directories: list) -> None:
"""
Удаляет указанные временные директории.
"""
for directory in directories:
if os.path.exists(directory):
shutil.rmtree(directory)
logger.info(f"Временная директория {directory} удалена")
def report_exists(run_name: str) -> bool:
"""
Проверяет наличие отчёта Allure в хранилище MinIO.
Данный метод используется для проверки существования HTML-отчета
о тестировании с определённым именем в указанном бакете MinIO.
Если отчёт существует, метод возвращает `True`, в противном
случае – `False`.
"""
try:
minio_client.minio_client.stat_object(
const.ALLURE_REPORTS_BUCKET_NAME, f"{run_name}.html"
)
return True
except S3Error:
return False
def log_and_abort(result_id: int, testrun) -> None:
"""
Логирование и окончание запроса, если тестран не существует или помечен как удаленный.
"""
if not testrun:
logger.error(f"Test run с ID {result_id} не найден.")
abort(404, description=f"Test run с ID {result_id} не найден.")
if testrun.is_deleted:
logger.warning(f"Test run {result_id} помечен как удаленный.")
abort(404, description=f"Test run {testrun.run_name} помечен как удаленный.")
def get_or_generate_report(run_name: str):
"""
Получение или генерация allure-report
Загрузка allure-report в MinIO
"""
if report_exists(run_name):
return minio_client.minio_client.get_object(
const.ALLURE_REPORTS_BUCKET_NAME, f"{run_name}.html"
)
generate_and_upload_report(run_name)
return minio_client.minio_client.get_object(
const.ALLURE_REPORTS_BUCKET_NAME, f"{run_name}.html"
)
руты
@bp.route("/upload", methods=["POST"])
def upload_results():
"""
API-метод для загрузки файлов и создания тестового запуска
"""
files = testrun_helpers.get_request_files()
testrun_helpers.check_files_size(files)
try:
testrun_helpers.validate_upload_files(files)
except testrun_helpers.UploadValidationError as exc:
logger.warning(
"Валидация загружаемых файлов не пройдена",
error_code=exc.code,
message=exc.message,
)
return flask.jsonify(
{
"success": False,
"error": exc.code,
"message": exc.message,
}
), 400
new_result = testrun_helpers.create_temp_test_result()
test_run_info = testrun_helpers.extract_test_run_info(files)
try:
testrun_helpers.update_test_result(new_result, test_run_info)
logger.info(f"Обновлены данные тестрана с ID: {new_result.id}")
except DatabaseError as error_msg:
db.session.rollback()
logger.exception("Ошибка при сохранении статуса тестрана в базу данных")
flask.abort(500, description=str(error_msg))
success_files, error_files = testrun_helpers.upload_all_files(
new_result.run_name, files
)
if success_files:
logger.info(f"Успешно загруженные файлы в MinIO: {', '.join(success_files)}")
if error_files:
logger.warning(f"Ошибка обработки следующих файлов: {', '.join(error_files)}")
flask.abort(500, description="Некоторые файлы не были успешно обработаны")
testrun = testrun_helpers.get_existing_run_or_abort(new_result.id)
testrun_helpers.get_or_generate_report(testrun.run_name)
response = flask.jsonify(
{
"success": True,
"run_id": new_result.id,
"message": "Файлы успешно загружены",
}
)
response_code = 201
logger.info("Файлы успешно загружены", status_code=response_code)
return response, response_code
static\js\reports.js
} else if (statusValue === "skipped") {
statusClass = "text-gray-500";
} else if (statusValue === "no_data") {
statusClass = "text-gray-400";
}