Skip to content

Commit b406ca5

Browse files
authored
Merge branch 'main' into SEP-1936
2 parents 069996a + e77bab1 commit b406ca5

83 files changed

Lines changed: 4943 additions & 473 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/sep/apps/atw/api_routes.py

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from app.core.pagination import PaginatedResponse
3535
from app.core.pagination.deps import PaginationDep
3636
from app.core.utils.date_time import utc_now
37+
from app.core.utils.fields import StrippedNonEmptyStr
3738
from app.core.utils.iterators import unique_everseen
3839
from app.sep.apps.atw.batch import (
3940
ATWBatchExecuteItemResponse,
@@ -65,11 +66,14 @@
6566
AtwIncidentDep,
6667
AtwSnippetSearchQueryDep,
6768
ClosedAtwIncidentDep,
69+
diagnostics_case_search_available,
6870
diagnostics_send_disabled_reasons,
6971
IsDiagnosticsSendConfigured,
7072
OpenAtwIncidentDep,
7173
)
7274
from app.sep.apps.atw.models import (
75+
AtwCaseMatch,
76+
AtwCaseSearchResponse,
7377
AtwConfigResponse,
7478
AtwIncident,
7579
AtwIncidentExecution,
@@ -83,7 +87,9 @@
8387
)
8488
from app.sep.apps.atw.schema import atw_schema
8589
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
8793
from app.sep.snippets.crud import SnippetManager
8894
from app.sep.snippets.masking import mask_snippet_args
8995
from app.sep.snippets.models import Snippet
@@ -105,6 +111,23 @@
105111
NO_TASK_ID_ERROR = "Dispatched, but the Tasks API returned no task id; not recorded."
106112
UNRECORDED_EXECUTION_ERROR = "Dispatched, but the execution row could not be recorded"
107113

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+
108131

109132
class ATWSnippetSummary(BaseModel):
110133
"""Represent one snippet entry under an ATW category.
@@ -628,12 +651,68 @@ def _build_execution_response(
628651
async def atw_config() -> AtwConfigResponse:
629652
"""Report whether the incident send action is available.
630653
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
632655
it must answer whether or not a receiver is configured.
633656
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.
635659
"""
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+
)
637716

638717

639718
async def _resolve_selected_executions(

app/sep/apps/atw/deps.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,19 @@ def diagnostics_send_disabled_reasons() -> list[str]:
9494
return []
9595

9696

97+
def diagnostics_case_search_available() -> bool:
98+
"""Report whether this deployment can search the receiver for cases.
99+
100+
Distinct from the send gate: delivery may be fully configured while the plan
101+
declares no case-search section, in which case the case-reference field
102+
stays the plain text input it has always been.
103+
104+
:return: Whether a case search can be issued.
105+
"""
106+
resolution = resolve_delivery_plan()
107+
return resolution.plan is not None and resolution.plan.case_search is not None
108+
109+
97110
async def require_diagnostics_send_configured() -> None:
98111
"""Raise if diagnostics delivery is not configured.
99112

app/sep/apps/atw/models.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,11 +276,41 @@ class AtwSendLogResponse(BaseModel):
276276
detail: dict[str, Any] = Field(json_schema_extra=ARBITRARY_ARGS_SCHEMA)
277277

278278

279+
class AtwCaseMatch(BaseModel):
280+
"""Represent one support case the delivery provider matched.
281+
282+
:param reference: The case reference to send diagnostics against.
283+
:param title: The case title, shown beside the reference to tell two
284+
similar references apart.
285+
"""
286+
287+
reference: str
288+
title: str
289+
290+
291+
class AtwCaseSearchResponse(BaseModel):
292+
"""Report the cases matching a typed term, or that the search could not run.
293+
294+
:param available: Whether the search ran at all. This is what keeps an
295+
unavailable search distinct from an available one that matched nothing:
296+
a caller must not render the first as the second.
297+
:param matches: The matched cases, empty when there are none and when the
298+
search could not run.
299+
"""
300+
301+
available: bool
302+
matches: list[AtwCaseMatch]
303+
304+
279305
class AtwConfigResponse(BaseModel):
280306
"""Report whether the incident send action is available.
281307
282308
:param send_disabled_reasons: Why sending is unavailable; empty when the
283309
receiver is configured and the action is offered.
310+
:param case_search_available: Whether the case-reference field may query the
311+
receiver for matches. Defaults to ``False`` so a client built against
312+
the response before this field existed keeps validating.
284313
"""
285314

286315
send_disabled_reasons: list[str]
316+
case_search_available: bool = False

app/sep/bundle_upload/factory.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@
3030

3131
from app.core.requests import RemoteAPI
3232
from app.sep.bundle_upload.plan import (
33+
AnyStepValue,
3334
DeliveryPlan,
3435
DeliveryPlanExecutor,
3536
LiteralValue,
36-
PlanValue,
3737
StepObserver,
3838
)
3939

@@ -69,13 +69,13 @@ def _rebase_path(prefix: str, path: str) -> str:
6969

7070

7171
def _rebase_query(
72-
query: Mapping[str, PlanValue], endpoint_query: dict[str, str]
73-
) -> dict[str, PlanValue]:
72+
query: Mapping[str, AnyStepValue], endpoint_query: dict[str, str]
73+
) -> dict[str, AnyStepValue]:
7474
"""Merge the endpoint's query pairs into a step's own, without displacing them.
7575
76-
Accepts any value map the plan admits, so a step whose values are drawn
77-
from a narrower set than :data:`~app.sep.bundle_upload.plan.PlanValue`,
78-
as the probe's are, rebases through the same helper.
76+
Accepts any value map any step kind admits, so one helper rebases them all:
77+
the probe's, drawn from a set narrower than the send steps', and the case
78+
search's, carrying a source the send steps have no value for.
7979
8080
:param query: The step's configured query map.
8181
:param endpoint_query: The query pairs carried by the configured endpoint.
@@ -132,8 +132,23 @@ def _rebased_plan(
132132
}
133133
)
134134
)
135+
case_search = (
136+
None
137+
if plan.case_search is None
138+
else plan.case_search.model_copy(
139+
update={
140+
"path": _rebase_path(path, plan.case_search.path),
141+
"query": _rebase_query(plan.case_search.query, query),
142+
}
143+
)
144+
)
135145
return plan.model_copy(
136-
update={"resolution_steps": steps, "upload": upload, "probe": probe}
146+
update={
147+
"resolution_steps": steps,
148+
"upload": upload,
149+
"probe": probe,
150+
"case_search": case_search,
151+
}
137152
)
138153

139154

0 commit comments

Comments
 (0)