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.
This commit is contained in:
febux 2026-09-06 01:18:33 +03:00
parent c00f3cf9f9
commit 0b2e7665f4
5 changed files with 29 additions and 29 deletions

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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()