From 0b2e7665f47f9e20e42165ec3fa9f5fa0a821b87 Mon Sep 17 00:00:00 2001 From: febux Date: Sun, 6 Sep 2026 01:18:33 +0300 Subject: [PATCH] fixup(pipeline-hardening M4/M5): purge count, shared toast helper, clean imports - Fix RabbitMQ purge_queue to report the pre-purge message count instead of the post-purge depth (which was always 0). - Add shared attach_toast helper in admin templating; remove duplicated inline _attach_toast implementations in review.py and mq.py. - Keep purge confirmation case-sensitive so the typed safeguard works as intended. - Fix test imports to use known-first-party 'contract_check' per isort config. --- src/contract_check/api/admin/mq.py | 21 +++++++++------------ src/contract_check/api/admin/review.py | 12 +++--------- src/contract_check/api/admin/templating.py | 8 ++++++++ src/contract_check/core/mq/management.py | 7 ++++--- tests/unit/test_mq_management.py | 10 +++++----- 5 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/contract_check/api/admin/mq.py b/src/contract_check/api/admin/mq.py index e0205fc..b5bbc2b 100644 --- a/src/contract_check/api/admin/mq.py +++ b/src/contract_check/api/admin/mq.py @@ -12,7 +12,7 @@ from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from fastapi.responses import HTMLResponse, RedirectResponse from src.contract_check.api.admin.auth import AdminUser, HtmxGuard, require_admin -from src.contract_check.api.admin.templating import templates +from src.contract_check.api.admin.templating import attach_toast, templates from src.contract_check.core.logging import get_logger from src.contract_check.core.mq.management import ( MqManagementDisabledError, @@ -36,12 +36,6 @@ def _client() -> RabbitMQManagementClient: return RabbitMQManagementClient() -def _attach_toast(response: RedirectResponse, toast: str) -> None: - import json - - response.headers["HX-Trigger"] = json.dumps({"showToast": toast}) - - def _safe_toast(toast: str) -> str: # Keep toast text short and URL-safe for redirects. return toast[:200] @@ -184,7 +178,7 @@ async def requeue_dlq( response = RedirectResponse( url=f"/admin/mq/dlq/{queue_name}", status_code=status.HTTP_303_SEE_OTHER ) - _attach_toast(response, toast) + attach_toast(response, toast) return response @@ -221,11 +215,14 @@ async def purge_dlq( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="unknown DLQ") expected = f"purge {queue_name}" - if confirmation.strip().lower() != expected.lower(): - return RedirectResponse( - url=f"/admin/mq/dlq/{queue_name}/purge/confirm?toast=Неверное+подтверждение.+Введите+%27{expected}%27", + if confirmation.strip() != expected: + toast = f"Неверное подтверждение. Введите '{expected}'" + response = RedirectResponse( + url=f"/admin/mq/dlq/{queue_name}/purge/confirm", status_code=status.HTTP_303_SEE_OTHER, ) + attach_toast(response, toast) + return response client = _client() try: @@ -252,5 +249,5 @@ async def purge_dlq( response = RedirectResponse( url=f"/admin/mq/dlq/{queue_name}", status_code=status.HTTP_303_SEE_OTHER ) - _attach_toast(response, toast) + attach_toast(response, toast) return response diff --git a/src/contract_check/api/admin/review.py b/src/contract_check/api/admin/review.py index ab7d4f4..c6a0e5d 100644 --- a/src/contract_check/api/admin/review.py +++ b/src/contract_check/api/admin/review.py @@ -7,7 +7,6 @@ All mutations are HTMX-driven and protected by the ``HX-Request`` CSRF guard. from __future__ import annotations import datetime as dt -import json import uuid from decimal import Decimal from typing import Annotated @@ -16,7 +15,7 @@ from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from fastapi.responses import HTMLResponse, RedirectResponse from src.contract_check.api.admin.auth import AdminUser, HtmxGuard, require_admin -from src.contract_check.api.admin.templating import templates +from src.contract_check.api.admin.templating import attach_toast, templates from src.contract_check.api.deps import AsyncSessionDep, PublisherDep from src.contract_check.core.config import get_settings from src.contract_check.core.db.repositories import ReviewQueueRepository @@ -124,7 +123,7 @@ async def list_review_queue( "conf_max": conf_max, }, ) - _attach_toast(response, request.query_params.get("toast")) + attach_toast(response, request.query_params.get("toast")) return response @@ -150,15 +149,10 @@ async def review_detail( "toast": request.query_params.get("toast"), }, ) - _attach_toast(response, request.query_params.get("toast")) + attach_toast(response, request.query_params.get("toast")) return response -def _attach_toast(response: HTMLResponse, toast: str | None) -> None: - if toast: - response.headers["HX-Trigger"] = json.dumps({"showToast": toast}) - - def _review_action_redirect( *, document_id: uuid.UUID, diff --git a/src/contract_check/api/admin/templating.py b/src/contract_check/api/admin/templating.py index d9e81c0..7e0f589 100644 --- a/src/contract_check/api/admin/templating.py +++ b/src/contract_check/api/admin/templating.py @@ -8,8 +8,10 @@ datetime rendering consistent. from __future__ import annotations import datetime as dt +import json from pathlib import Path +from fastapi import Response from fastapi.templating import Jinja2Templates _TEMPLATES_DIR = Path(__file__).resolve().parent / "templates" @@ -33,5 +35,11 @@ def _yes_no_none(value: object) -> str: return "—" +def attach_toast(response: Response, toast: str | None) -> None: + """Attach an HTMX toast trigger to any response that supports headers.""" + if toast: + response.headers["HX-Trigger"] = json.dumps({"showToast": toast}) + + templates.env.filters["dt"] = _format_dt templates.env.filters["yn"] = _yes_no_none diff --git a/src/contract_check/core/mq/management.py b/src/contract_check/core/mq/management.py index b656dd9..017a25b 100644 --- a/src/contract_check/core/mq/management.py +++ b/src/contract_check/core/mq/management.py @@ -240,6 +240,9 @@ class RabbitMQManagementClient: """Empty a queue and return the number of messages removed.""" self._ensure_enabled() url = f"/api/queues/{self._vhost}/{quote(queue_name, safe='')}/contents" + # Capture the pre-purge depth so we can report how many were removed. + before_info = await self.get_queue_info(queue_name) + messages_before = max(0, before_info.messages) try: resp = await self.client.delete(url) except httpx.NetworkError as exc: @@ -254,9 +257,7 @@ class RabbitMQManagementClient: status_code=resp.status_code, body=resp.text[:500], ) - # Successful purge returns no body; the queue is now empty. - info = await self.get_queue_info(queue_name) - return PurgeResult(messages_removed=max(0, info.messages)) + return PurgeResult(messages_removed=messages_before) async def requeue_batch( self, diff --git a/tests/unit/test_mq_management.py b/tests/unit/test_mq_management.py index 9e1a393..e68df8e 100644 --- a/tests/unit/test_mq_management.py +++ b/tests/unit/test_mq_management.py @@ -12,7 +12,7 @@ import httpx import pytest import respx -from src.contract_check.core.mq.management import ( +from contract_check.core.mq.management import ( MqManagementDisabledError, MqManagementNotFoundError, MqManagementResponseError, @@ -23,7 +23,7 @@ from src.contract_check.core.mq.management import ( RabbitMQManagementClient, RequeueResult, ) -from src.contract_check.core.mq.topology import DLQ_FOR +from contract_check.core.mq.topology import DLQ_FOR @pytest.fixture @@ -132,18 +132,18 @@ async def test_peek_messages_empty_non_list(mgmt_client: RabbitMQManagementClien @pytest.mark.anyio -async def test_purge_queue(mgmt_client: RabbitMQManagementClient) -> None: +async def test_purge_queue_reports_pre_purge_count(mgmt_client: RabbitMQManagementClient) -> None: with respx.mock: respx.delete("http://rabbitmq:15672/api/queues/%2F/extract.dlq/contents").mock( return_value=httpx.Response(204) ) respx.get("http://rabbitmq:15672/api/queues/%2F/extract.dlq").mock( return_value=httpx.Response( - 200, json={"name": "extract.dlq", "messages": 0, "state": "running"} + 200, json={"name": "extract.dlq", "messages": 3, "state": "running"} ) ) await mgmt_client.connect() - assert await mgmt_client.purge_queue("extract.dlq") == PurgeResult(messages_removed=0) + assert await mgmt_client.purge_queue("extract.dlq") == PurgeResult(messages_removed=3) await mgmt_client.aclose()