|
34 | 34 | from app.core.pagination import PaginatedResponse |
35 | 35 | from app.core.pagination.deps import PaginationDep |
36 | 36 | from app.core.utils.date_time import utc_now |
| 37 | +from app.core.utils.fields import StrippedNonEmptyStr |
37 | 38 | from app.core.utils.iterators import unique_everseen |
38 | 39 | from app.sep.apps.atw.batch import ( |
39 | 40 | ATWBatchExecuteItemResponse, |
|
65 | 66 | AtwIncidentDep, |
66 | 67 | AtwSnippetSearchQueryDep, |
67 | 68 | ClosedAtwIncidentDep, |
| 69 | + diagnostics_case_search_available, |
68 | 70 | diagnostics_send_disabled_reasons, |
69 | 71 | IsDiagnosticsSendConfigured, |
70 | 72 | OpenAtwIncidentDep, |
71 | 73 | ) |
72 | 74 | from app.sep.apps.atw.models import ( |
| 75 | + AtwCaseMatch, |
| 76 | + AtwCaseSearchResponse, |
73 | 77 | AtwConfigResponse, |
74 | 78 | AtwIncident, |
75 | 79 | AtwIncidentExecution, |
|
83 | 87 | ) |
84 | 88 | from app.sep.apps.atw.schema import atw_schema |
85 | 89 | from app.sep.apps.framework.api import schema_endpoint |
86 | | -from app.sep.deps import ApiCurrentUser, SessionDep, TaskAPI |
| 90 | +from app.sep.bundle_upload.factory import get_delivery_executor |
| 91 | +from app.sep.bundle_upload.resolver import resolve_delivery_plan |
| 92 | +from app.sep.deps import ApiCurrentUser, IsApiAdmin, SessionDep, TaskAPI |
87 | 93 | from app.sep.snippets.crud import SnippetManager |
88 | 94 | from app.sep.snippets.masking import mask_snippet_args |
89 | 95 | from app.sep.snippets.models import Snippet |
|
105 | 111 | NO_TASK_ID_ERROR = "Dispatched, but the Tasks API returned no task id; not recorded." |
106 | 112 | UNRECORDED_EXECUTION_ERROR = "Dispatched, but the execution row could not be recorded" |
107 | 113 |
|
| 114 | +#: How long a case search may take before the field falls back to free text. |
| 115 | +#: Deliberately far below the delivery probe's 15s and the intra-cluster 5s: |
| 116 | +#: those bound a one-off operator action, while this is issued while someone is |
| 117 | +#: still typing. ``RemoteAPI`` carries only a session-level timeout |
| 118 | +#: (``sock_read=120``), so this is what actually bounds the call. |
| 119 | +CASE_SEARCH_TIMEOUT_SECONDS = 3 |
| 120 | + |
| 121 | +#: The longest search term the route forwards to the receiver. A case reference |
| 122 | +#: or a title fragment is far shorter; the cap is what keeps an arbitrary string |
| 123 | +#: out of the provider's query. |
| 124 | +MAX_CASE_SEARCH_TERM_LENGTH = 128 |
| 125 | + |
| 126 | +#: The most matches the route offers the dialog. ``CaseSearchStep`` declares no |
| 127 | +#: limit of its own, so without this the response's cardinality is whatever the |
| 128 | +#: receiver returns. |
| 129 | +MAX_CASE_SEARCH_MATCHES = 25 |
| 130 | + |
108 | 131 |
|
109 | 132 | class ATWSnippetSummary(BaseModel): |
110 | 133 | """Represent one snippet entry under an ATW category. |
@@ -628,12 +651,68 @@ def _build_execution_response( |
628 | 651 | async def atw_config() -> AtwConfigResponse: |
629 | 652 | """Report whether the incident send action is available. |
630 | 653 |
|
631 | | - Not gated by the send guard -- this endpoint is what reports that guard, so |
| 654 | + Not gated by the send guard: this endpoint is what reports that guard, so |
632 | 655 | it must answer whether or not a receiver is configured. |
633 | 656 |
|
634 | | - :return: The reasons the send action is withheld; empty when it is offered. |
| 657 | + :return: The reasons the send action is withheld, and whether the |
| 658 | + case-reference field may search the receiver. |
635 | 659 | """ |
636 | | - return AtwConfigResponse(send_disabled_reasons=diagnostics_send_disabled_reasons()) |
| 660 | + return AtwConfigResponse( |
| 661 | + send_disabled_reasons=diagnostics_send_disabled_reasons(), |
| 662 | + case_search_available=diagnostics_case_search_available(), |
| 663 | + ) |
| 664 | + |
| 665 | + |
| 666 | +@router.get("/case-search/", dependencies=[IsApiAdmin]) |
| 667 | +async def atw_case_search( |
| 668 | + term: Annotated[ |
| 669 | + StrippedNonEmptyStr, |
| 670 | + Query( |
| 671 | + max_length=MAX_CASE_SEARCH_TERM_LENGTH, |
| 672 | + description="The support case reference or title fragment to match.", |
| 673 | + ), |
| 674 | + ], |
| 675 | +) -> AtwCaseSearchResponse: |
| 676 | + """Search the configured delivery provider for support cases matching ``term``. |
| 677 | +
|
| 678 | + No way the search itself can fail reaches the caller as an error: a |
| 679 | + deployment that declares no case-search section, stored inputs that no |
| 680 | + longer fit the plan, a refused credential, an unreachable receiver and a |
| 681 | + search that outran its bound all report the same unavailability, which the |
| 682 | + caller renders as the plain text field rather than as a search that found |
| 683 | + nothing. |
| 684 | +
|
| 685 | + Restricted to administrators, unlike the app's other reads. The router |
| 686 | + resolves a minimum role for unsafe methods only, so a safe method carries |
| 687 | + whatever guard it declares itself; this one issues the deployment's own |
| 688 | + receiver credential, and the dialog that calls it is already offered to |
| 689 | + administrators alone. |
| 690 | +
|
| 691 | + :param term: The caller's typed search term, the only input it accepts. |
| 692 | + Surrounding whitespace is stripped, so a whitespace-only term is |
| 693 | + refused rather than reaching the receiver as a match-everything |
| 694 | + fragment. |
| 695 | + :return: The matched cases, or that the search could not run. At most |
| 696 | + ``MAX_CASE_SEARCH_MATCHES`` are offered, so a plan that declares no |
| 697 | + provider-side limit still cannot hand the dialog an unbounded list. |
| 698 | + """ |
| 699 | + plan = resolve_delivery_plan().plan |
| 700 | + if plan is None or plan.case_search is None: |
| 701 | + return AtwCaseSearchResponse(available=False, matches=[]) |
| 702 | + try: |
| 703 | + async with asyncio.timeout(CASE_SEARCH_TIMEOUT_SECONDS): |
| 704 | + async with get_delivery_executor(plan) as executor: |
| 705 | + matches = await executor.search_cases(term) |
| 706 | + except Exception: # noqa: BLE001 -- degraded, never surfaced to the dialog |
| 707 | + logger.warning("Diagnostics case search failed.", exc_info=True) |
| 708 | + return AtwCaseSearchResponse(available=False, matches=[]) |
| 709 | + return AtwCaseSearchResponse( |
| 710 | + available=True, |
| 711 | + matches=[ |
| 712 | + AtwCaseMatch(reference=match.reference, title=match.title) |
| 713 | + for match in matches[:MAX_CASE_SEARCH_MATCHES] |
| 714 | + ], |
| 715 | + ) |
637 | 716 |
|
638 | 717 |
|
639 | 718 | async def _resolve_selected_executions( |
|
0 commit comments