Coverage for netbox_data_import/proposal_presentation.py: 98.62%

218 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-30 09:47 +0000

1# SPDX-License-Identifier: Apache-2.0 

2# SPDX-FileCopyrightText: 2026 Marcin Zieba <marcinpsk@gmail.com> 

3"""Supply proposal cards and action reasons to the Review Workspace.""" 

4 

5from urllib.parse import urlencode 

6 

7from core.choices import JobStatusChoices 

8from django.core.exceptions import ValidationError 

9from django.db.models import F, Window 

10from django.db.models.functions import RowNumber 

11from django.urls import reverse 

12from django.utils.timesince import timesince 

13 

14from .field_keys import SELECT_TERMINATION_TASK, TERMINATION_ROLE, is_outside_claim, parse_termination_field_key 

15from .inference_backend import ( 

16 NoActiveInferenceBackend, 

17 proposal_candidate_limit, 

18 proposal_eligible_set_limit, 

19 resolve_active_backend, 

20) 

21from .inference_trust import InvalidInferenceConfiguration 

22from .models import ImportProfile, ProposalDecision, ProposalOutcome, ProposalStatus, ResolutionProposal 

23from .proposal_decisions import proposal_staleness 

24from .review_workspace import OUTSIDE_CLAIM_MARK, TERMINATION_UNRESOLVABLE 

25from .resolution_proposals import page_exhausted 

26from .proposal_tasks import CandidateSnapshot, proposal_task 

27from .cable_target import AUTOMATICALLY_RESOLVED, MANUALLY_RESOLVED, UNRESOLVED 

28 

29#: The badge modifier each field state wears, so the template needs no state vocabulary of its own. 

30STATE_STYLES = { 

31 "": "unknown", 

32 UNRESOLVED: "unresolved", 

33 AUTOMATICALLY_RESOLVED: "auto", 

34 MANUALLY_RESOLVED: "manual", 

35 "proposed": "proposed", 

36 ProposalDecision.ACCEPTED: "accepted", 

37 "stale": "stale", 

38 ProposalStatus.FAILED: "failed", 

39} 

40 

41RECENT_PROPOSAL_HISTORY_LIMIT = 10 

42WAITING_FOR_SLOT = "Waiting for a free AI backend slot..." 

43WAITING_FOR_BACKEND = "Waiting for the backend..." 

44REQUEST_PERMISSION_REASON = "You do not have permission to request proposals for this Import Profile." 

45 

46 

47def group_terminations(fields): 

48 """Keep exact matches without proposal history in the compact settled group. 

49 

50 An end of a segment NetBox cannot cable stays in attention, so its picker can correct the match. 

51 """ 

52 attention, settled = [], [] 

53 for field in fields: 

54 settles = ( 

55 field["proposal"]["field_state"] == AUTOMATICALLY_RESOLVED 

56 and not field["proposal_read"]["history_display"] 

57 and not field["incompatible"] 

58 ) 

59 (settled if settles else attention).append(field) 

60 return attention, settled 

61 

62 

63def backend_unavailable_reason() -> str: 

64 """Return why no proposal can reach an Inference Backend now, or an empty string.""" 

65 try: 

66 resolve_active_backend() 

67 except NoActiveInferenceBackend: 

68 return "No Inference Backend is enabled or configured as a fallback." 

69 except (InvalidInferenceConfiguration, ValidationError): 

70 return "The active Inference Backend configuration is invalid." 

71 return "" 

72 

73 

74#: The commands each card renders beside the termination picker; the others sit in the proposal display. 

75FIELD_ACTIONS = frozenset({"request", "cancel"}) 

76 

77 

78def _action(key, label, reason, *, hidden, disabled=None): 

79 """Return one command. A hidden command shows nothing; a disabled one states only its own refusal. 

80 

81 A failed or stale proposal refuses its decisions as a whole, and the card states that once. 

82 """ 

83 return { 

84 "key": key, 

85 "label": label, 

86 "reason": "" if hidden else reason, 

87 "hidden": hidden, 

88 "disabled": not hidden and (bool(reason) if disabled is None else disabled), 

89 "url": reverse(f"plugins:netbox_data_import:trace_{key}_proposal"), 

90 } 

91 

92 

93class TerminationInventories: 

94 """Read one inventory per Device, kind, and role, for the card display and for every request.""" 

95 

96 def __init__(self, *, profile, reader): 

97 self.profile = profile 

98 self.reader = reader 

99 self._reads = {} 

100 

101 def get(self, field_key): 

102 """Return the cached inventory for the Device, kind, and role that *field_key* names.""" 

103 parsed = parse_termination_field_key(field_key) 

104 key = (parsed["device"], parsed["kind"], parsed["role"]) 

105 if key not in self._reads: 

106 self._reads[key] = proposal_task(SELECT_TERMINATION_TASK).inventory( 

107 profile=self.profile, 

108 field_key=field_key, 

109 netbox_reader=self.reader, 

110 limit=proposal_eligible_set_limit(), 

111 ) 

112 return self._reads[key] 

113 

114 

115class ProposalPresentation: 

116 """Read one profile's proposal display with one backend lookup per response.""" 

117 

118 def __init__(self, *, profile, actor, reader): 

119 self.profile = profile 

120 self.actor = actor 

121 self.reader = reader 

122 self._inventories = TerminationInventories(profile=profile, reader=reader) 

123 self._write_assessments = {} 

124 self.preview_allowed = ImportProfile.objects.restrict(actor, "change").filter(pk=profile.pk).exists() 

125 self.profile_view_allowed = ImportProfile.objects.restrict(actor, "view").filter(pk=profile.pk).exists() 

126 self.view_reason = "" 

127 if not self.profile_view_allowed: 

128 self.view_reason = "You do not have permission to view proposals for this Import Profile." 

129 self.backend_reason = backend_unavailable_reason() 

130 

131 @property 

132 def request_block_reason(self) -> str: 

133 """Explain why this operator can ask about no field of the profile, or return an empty string.""" 

134 if self.view_reason: 

135 return self.view_reason 

136 if not self.preview_allowed: 

137 return REQUEST_PERMISSION_REASON 

138 return self.backend_reason 

139 

140 def fields(self, fields): 

141 """Return each displayed field and its bounded history within the authorized profile.""" 

142 if self.view_reason: 

143 return {field["field_key"]: self.field(field, None, [], False) for field in fields} 

144 histories: dict[str, list] = {field["field_key"]: [] for field in fields} 

145 rows = ( 

146 ResolutionProposal.objects.filter( 

147 profile=self.profile, task_type=SELECT_TERMINATION_TASK, field_key__in=histories 

148 ) 

149 .annotate( 

150 history_position=Window( 

151 expression=RowNumber(), 

152 partition_by=F("field_key"), 

153 order_by=(F("created").desc(), F("pk").desc()), 

154 ) 

155 ) 

156 .filter(history_position__lte=RECENT_PROPOSAL_HISTORY_LIMIT + 1) 

157 .only("pk", "field_key", "created", "status", "outcome", "decision", "failure_reason") 

158 .order_by("field_key", "history_position") 

159 ) 

160 for row in rows: 

161 histories[row.field_key].append(row) 

162 current_ids = [history[0].pk for history in histories.values() if history] 

163 current = { 

164 proposal.pk: proposal 

165 for proposal in ResolutionProposal.objects.filter(pk__in=current_ids).select_related( 

166 "profile", "resolved_device_type", "selected_object_type", "written_resolution", "job" 

167 ) 

168 } 

169 return { 

170 field["field_key"]: self.field( 

171 field, 

172 current.get(history[0].pk) if history else None, 

173 history[:RECENT_PROPOSAL_HISTORY_LIMIT], 

174 len(history) > RECENT_PROPOSAL_HISTORY_LIMIT, 

175 ) 

176 for field in fields 

177 for history in (histories[field["field_key"]],) 

178 } 

179 

180 def field(self, field, proposal, history, history_has_more): 

181 """Serialize the current attempt, freshness, actions, and recent summaries.""" 

182 record = {"id": proposal.pk} if proposal is not None else None 

183 history_url = None 

184 if history: 

185 query = urlencode({"profile_id": self.profile.pk, "field_key": field["field_key"]}) 

186 history_url = f"{reverse('plugins-api:netbox_data_import-api:resolutionproposalhistory-list')}?{query}" 

187 payload = { 

188 "proposal": record, 

189 "history_display": [ 

190 { 

191 "id": row.pk, 

192 "created": row.created.isoformat(), 

193 "status": row.get_status_display(), 

194 "outcome": row.get_outcome_display() or "No outcome", 

195 "decision": row.get_decision_display(), 

196 "failure": row.get_failure_reason_display(), 

197 } 

198 for row in history 

199 ], 

200 "history_has_more": history_has_more, 

201 "history_url": history_url, 

202 "staleness": None, 

203 } 

204 inventory = self.field_inventory(field) if proposal is not None or self.preview_allowed else None 

205 if self.reader is None: 

206 payload["staleness_error"] = "The saved import target is gone or outside your view scope." 

207 elif proposal is not None: 

208 stale = proposal_staleness(proposal, inventory=inventory) 

209 payload["staleness"] = { 

210 "is_stale": stale.is_stale, 

211 "resolved_device_changed": stale.resolved_device_changed, 

212 "candidates_changed": stale.candidates_changed, 

213 } 

214 payload["presentation"] = self.card(field, proposal, payload, inventory) 

215 return payload 

216 

217 def field_inventory(self, field): 

218 """Return one cached inventory read for fields that share device, kind, and role.""" 

219 if self.reader is None or parse_termination_field_key(field["field_key"])["role"] != TERMINATION_ROLE: 

220 return None 

221 return self._inventories.get(field["field_key"]) 

222 

223 def action_permission_reason(self, field, inventory): 

224 """Explain access shared by request and cancellation actions.""" 

225 if not self.preview_allowed: 

226 return REQUEST_PERMISSION_REASON 

227 if parse_termination_field_key(field["field_key"])["role"] != TERMINATION_ROLE: 

228 return "Ask AI supports termination fields only. Choose the mapped peer manually." 

229 if inventory is None or inventory.resolved_device is None: 

230 return "The resolved Device is unavailable or outside your view permission." 

231 return "" 

232 

233 def request_permission_reason(self, field, inventory): 

234 """Explain access and candidate eligibility for a proposal request.""" 

235 if field.get("source_ambiguous", False): 

236 return TERMINATION_UNRESOLVABLE 

237 reason = self.action_permission_reason(field, inventory) 

238 if reason: 

239 return reason 

240 if inventory.candidate_error is not None: 

241 return str(inventory.candidate_error) 

242 return "" 

243 

244 def card(self, field, proposal, payload, inventory): 

245 """Derive all proposal vocabulary and legal actions in one place.""" 

246 pending = proposal is not None and proposal.status in ProposalStatus.ACTIVE 

247 # A queued row without a Job waits for the dispatcher to free a request slot. 

248 waiting = pending and proposal.status == ProposalStatus.QUEUED and proposal.job_id is None 

249 completed = proposal is not None and proposal.status == ProposalStatus.COMPLETED 

250 state = field["state"] 

251 # The plan owns whether a field is resolved; acceptance only renames a state it already set. 

252 if state != UNRESOLVED and proposal is not None and proposal.decision == ProposalDecision.ACCEPTED: 

253 resolution = proposal.written_resolution 

254 if resolution is not None and ( 

255 resolution.selected_object_type_id == proposal.selected_object_type_id 

256 and resolution.selected_object_id == proposal.selected_object_id 

257 ): 

258 state = ProposalDecision.ACCEPTED 

259 stale = payload["staleness"] 

260 stale_reason = payload.get("staleness_error", "") 

261 if stale and stale["is_stale"]: 

262 stale_reason = "The resolved Device or eligible candidates changed. Request a new proposal." 

263 candidate, missing, selected_entry = ("", False, None) if stale_reason else self.selected_candidate(proposal) 

264 if missing: 

265 # Acceptance refuses this row, so the card must not offer an action the writer declines. 

266 stale_reason = "The selected candidate is no longer in the request snapshot. Request a new proposal." 

267 offered = self.offered_page(proposal) 

268 actions = self.actions( 

269 field, 

270 proposal, 

271 state, 

272 pending, 

273 completed, 

274 stale_reason, 

275 selected_entry, 

276 inventory, 

277 offered, 

278 ) 

279 badge = proposal.get_status_display() if proposal is not None else "No proposal" 

280 if completed: 

281 badge = "Proposal - stale, not applied" if stale_reason else "Proposal - not applied" 

282 if proposal is not None and proposal.decision: 

283 badge = proposal.get_decision_display() 

284 if proposal.decision == ProposalDecision.ACCEPTED and state == UNRESOLVED: 

285 badge = "Accepted resolution no longer applies" 

286 if state == UNRESOLVED and proposal is not None and not proposal.decision: 

287 if pending or proposal.outcome == ProposalOutcome.CANDIDATE: 

288 state = "proposed" 

289 if completed and stale_reason: 

290 state = "stale" 

291 if proposal.status == ProposalStatus.FAILED: 

292 state = ProposalStatus.FAILED 

293 return { 

294 "has_proposal": proposal is not None, 

295 "pending": pending, 

296 "progress": WAITING_FOR_SLOT if waiting else WAITING_FOR_BACKEND, 

297 "field_state": state, 

298 "state_style": STATE_STYLES[state], 

299 "badge": badge, 

300 "candidate": candidate, 

301 # A proposal is requested for the termination role only, so its field key states the source claim. 

302 "outside_claim": OUTSIDE_CLAIM_MARK 

303 if selected_entry is not None 

304 and is_outside_claim(parse_termination_field_key(field["field_key"])["kind"], selected_entry.object_type) 

305 else "", 

306 "explanation": proposal.explanation if proposal is not None else "", 

307 "failure": proposal.get_failure_reason_display() if proposal is not None else "", 

308 "failure_code": proposal.failure_reason if proposal is not None else "", 

309 "job_status": self.job_status(proposal) if pending else "", 

310 "job_note": self.job_note(proposal) if pending else "", 

311 "page_status": self.page_status(offered) if completed else "", 

312 "no_match": completed and proposal.outcome == ProposalOutcome.NO_MATCH, 

313 # Accept can be hidden, so the card itself warns that an undecided answer no longer applies. 

314 "warning": stale_reason if completed and not proposal.decision else "", 

315 "actions": actions, 

316 "field_actions": [action for action in actions if action["key"] in FIELD_ACTIONS and not action["hidden"]], 

317 "decision_actions": [ 

318 action for action in actions if action["key"] not in FIELD_ACTIONS and not action["hidden"] 

319 ], 

320 } 

321 

322 @staticmethod 

323 def offered_page(proposal): 

324 """Return the page one attempt offered, or None when it offered the whole eligible set.""" 

325 if proposal is None: 

326 return None 

327 snapshot = CandidateSnapshot.from_json(proposal.candidate_snapshot) 

328 return snapshot if snapshot.total > len(snapshot.page) else None 

329 

330 @staticmethod 

331 def page_status(offered): 

332 """Name the candidates one attempt searched, which is what a no_match is bounded by.""" 

333 if offered is None: 

334 return "" 

335 return f"Searched candidates {offered.page_offset + 1}-{offered.page_end} of {offered.total}." 

336 

337 @staticmethod 

338 def job_status(proposal): 

339 """Say where the background job stands, which a card waiting on one cannot otherwise show.""" 

340 job = proposal.job 

341 if job is not None: 

342 state = job.get_status_display() 

343 else: 

344 state = "not started yet" if proposal.status == ProposalStatus.QUEUED else "none recorded" 

345 return f"Background job: {state}, requested {timesince(proposal.created)} ago" 

346 

347 @staticmethod 

348 def job_note(proposal): 

349 """Name the two states in which an active attempt is never going to get an answer.""" 

350 job = proposal.job 

351 if job is None and proposal.status == ProposalStatus.QUEUED: 

352 return "" 

353 if job is None: 

354 return "No background job is recorded. Cancel this proposal and ask again." 

355 if job.status in JobStatusChoices.TERMINAL_STATE_CHOICES: 

356 return "The background job ended without recording a result. Cancel this proposal and ask again." 

357 return "" 

358 

359 @staticmethod 

360 def selected_candidate(proposal): 

361 """Return the chosen candidate's display, and whether the snapshot no longer holds it.""" 

362 if proposal is None or proposal.outcome != ProposalOutcome.CANDIDATE: 

363 return "", False, None 

364 snapshot = CandidateSnapshot.from_json(proposal.candidate_snapshot) 

365 entry = next( 

366 (row for row in snapshot.entries if row.candidate_id == proposal.selected_candidate_id), 

367 None, 

368 ) 

369 if entry is None: 

370 return "", True, None 

371 return f"{entry.display_name} ({str(proposal.selected_object_type.name).capitalize()})", False, entry 

372 

373 def actions(self, field, proposal, state, pending, completed, stale_reason, selected_entry, inventory, offered): 

374 """Return every command with its own refusal, hiding a command the lifecycle rules out.""" 

375 failed = proposal is not None and proposal.status == ProposalStatus.FAILED 

376 decided = proposal is not None and bool(proposal.decision) 

377 decision_hidden = decided or not (completed or failed) 

378 no_match = completed and proposal.outcome == ProposalOutcome.NO_MATCH 

379 accept_reason = TERMINATION_UNRESOLVABLE if field.get("source_ambiguous", False) else "" 

380 write_refused = not self.preview_allowed or ( 

381 selected_entry is not None and "source" in field and not self.write_allowed(field, proposal, selected_entry) 

382 ) 

383 if write_refused: 

384 accept_reason = "You do not have permission to save a termination resolution." 

385 reject_reason = "" if self.profile_view_allowed else "You do not have permission to reject proposals." 

386 actions = [ 

387 _action( 

388 "request", 

389 self.request_label(proposal, offered, stale_reason), 

390 self.request_permission_reason(field, inventory) or self.backend_reason, 

391 hidden=state != UNRESOLVED or pending, 

392 ), 

393 _action("cancel", "Cancel", self.action_permission_reason(field, inventory), hidden=not pending), 

394 _action( 

395 "accept", 

396 "Accept", 

397 accept_reason, 

398 hidden=decision_hidden or no_match, 

399 disabled=failed or bool(stale_reason) or bool(accept_reason), 

400 ), 

401 _action("reject", "Reject", reject_reason, hidden=decision_hidden, disabled=failed or bool(reject_reason)), 

402 ] 

403 if self.view_reason: 

404 # Without view access the card shows no proposal, so it states the refusal on every command. 

405 return [{**action, "reason": self.view_reason, "hidden": False, "disabled": True} for action in actions] 

406 return actions 

407 

408 def write_allowed(self, field, proposal, selected_entry) -> bool: 

409 """Return whether this actor may write the resolution the selected candidate names, read once per write.""" 

410 assessment_key = ( 

411 proposal.field_key, 

412 tuple(field["source"][part] for part in ("device", "cards", "port")), 

413 selected_entry.object_type, 

414 selected_entry.object_id, 

415 selected_entry.display_name, 

416 ) 

417 if assessment_key not in self._write_assessments: 

418 self._write_assessments[assessment_key] = proposal_task(SELECT_TERMINATION_TASK).assess_resolution_write( 

419 profile=self.profile, 

420 field_key=proposal.field_key, 

421 entry=selected_entry, 

422 source=field["source"], 

423 actor=self.actor, 

424 ) 

425 return self._write_assessments[assessment_key].allowed 

426 

427 @staticmethod 

428 def next_page_size(offered) -> int: 

429 """Return how many candidates the next request offers, which is one page, not the rest.""" 

430 if offered is None or not offered.has_next_page: 

431 return 0 

432 return min(offered.total - offered.page_end, proposal_candidate_limit()) 

433 

434 @classmethod 

435 def request_label(cls, proposal, offered, stale_reason) -> str: 

436 """Name what the next request does: retry, continue the search, or start one.""" 

437 if proposal is not None and proposal.status == ProposalStatus.FAILED: 

438 return "Ask AI again" 

439 if proposal is None or stale_reason or not page_exhausted(proposal): 

440 return "Ask AI" 

441 if offering := cls.next_page_size(offered): 

442 return f"Ask AI: next {offering}" 

443 return "Ask AI"