Coverage for custom_components/supernotify/notification.py: 11%

439 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2026-01-07 15:35 +0000

1import datetime as dt 

2import logging 

3import uuid 

4from traceback import format_exception 

5from typing import TYPE_CHECKING, Any, cast 

6 

7import voluptuous as vol 

8from homeassistant.components.notify.const import ATTR_DATA 

9from voluptuous import humanize 

10 

11from . import ( 

12 ACTION_DATA_SCHEMA, 

13 ATTR_ACTION_GROUPS, 

14 ATTR_ACTIONS, 

15 ATTR_DEBUG, 

16 ATTR_DELIVERY, 

17 ATTR_DELIVERY_SELECTION, 

18 ATTR_MEDIA, 

19 ATTR_MEDIA_CLIP_URL, 

20 ATTR_MEDIA_SNAPSHOT_URL, 

21 ATTR_MESSAGE_HTML, 

22 ATTR_PERSON_ID, 

23 ATTR_PRIORITY, 

24 ATTR_RECIPIENTS, 

25 ATTR_SCENARIOS_APPLY, 

26 ATTR_SCENARIOS_CONSTRAIN, 

27 ATTR_SCENARIOS_REQUIRE, 

28 DELIVERY_SELECTION_EXPLICIT, 

29 DELIVERY_SELECTION_FIXED, 

30 DELIVERY_SELECTION_IMPLICIT, 

31 OPTION_UNIQUE_TARGETS, 

32 PRIORITY_MEDIUM, 

33 PRIORITY_VALUES, 

34 STRICT_ACTION_DATA_SCHEMA, 

35 TARGET_USE_FIXED, 

36 TARGET_USE_MERGE_ALWAYS, 

37 TARGET_USE_MERGE_ON_DELIVERY_TARGETS, 

38 TARGET_USE_ON_NO_ACTION_TARGETS, 

39 TARGET_USE_ON_NO_DELIVERY_TARGETS, 

40 SelectionRank, 

41) 

42from .archive import ArchivableObject 

43from .common import ensure_list, nullable_ensure_list, sanitize 

44from .context import Context 

45from .delivery import Delivery, DeliveryRegistry 

46from .envelope import Envelope 

47from .model import ConditionVariables, DebugTrace, DeliveryCustomization, SuppressionReason, Target, TargetRequired 

48from .people import Recipient 

49 

50if TYPE_CHECKING: 

51 from .people import PeopleRegistry 

52 from .scenario import Scenario 

53 from .transport import ( 

54 Transport, 

55 ) 

56 

57_LOGGER = logging.getLogger(__name__) 

58 

59# Deliveries mapping keys for debug / archive 

60KEY_DELIVERED = "delivered" 

61KEY_SUPPRESSED = "suppressed" 

62KEY_FAILED = "failed" 

63KEY_SKIPPED = "skipped" 

64 

65type t_delivery_name = str 

66type t_outcome = str 

67 

68 

69class Notification(ArchivableObject): 

70 def __init__( 

71 self, 

72 context: Context, 

73 message: str | None = None, 

74 title: str | None = None, 

75 target: list[str] | str | None = None, 

76 action_data: dict[str, Any] | None = None, 

77 ) -> None: 

78 self.created: dt.datetime = dt.datetime.now(tz=dt.UTC) 

79 self.debug_trace: DebugTrace = DebugTrace(message=message, title=title, data=action_data, target=target) 

80 self.message: str | None = message 

81 self.context: Context = context 

82 self.people_registry: PeopleRegistry = context.people_registry 

83 self.delivery_registry: DeliveryRegistry = context.delivery_registry 

84 action_data = action_data or {} 

85 self._target: Target | None = Target(target) if target else None 

86 self._already_selected: Target = Target() 

87 self._title: str | None = title 

88 self.id = str(uuid.uuid1()) 

89 self.delivered: int = 0 

90 self.error_count: int = 0 

91 self.skipped: int = 0 

92 self.failed: int = 0 

93 self.suppressed: int = 0 

94 self.dupe: bool = False 

95 self.deliveries: dict[t_delivery_name, dict[t_outcome, list[str] | list[Envelope] | dict[str, Any]]] = {} 

96 self._skip_reasons: list[SuppressionReason] = [] 

97 

98 self.validate_action_data(action_data) 

99 # for compatibility with other notify calls, pass thru surplus data to underlying delivery transports 

100 self.extra_data: dict[str, Any] = { 

101 k: v for k, v in action_data.items() if k not in STRICT_ACTION_DATA_SCHEMA(action_data) 

102 } 

103 action_data = {k: v for k, v in action_data.items() if k not in self.extra_data} 

104 

105 self.priority: str = action_data.get(ATTR_PRIORITY, PRIORITY_MEDIUM) 

106 self.message_html: str | None = action_data.get(ATTR_MESSAGE_HTML) 

107 self.required_scenario_names: list[str] = ensure_list(action_data.get(ATTR_SCENARIOS_REQUIRE)) 

108 self.applied_scenario_names: list[str] = ensure_list(action_data.get(ATTR_SCENARIOS_APPLY)) 

109 self.constrain_scenario_names: list[str] = ensure_list(action_data.get(ATTR_SCENARIOS_CONSTRAIN)) 

110 self.delivery_selection: str | None = action_data.get(ATTR_DELIVERY_SELECTION) 

111 self.delivery_overrides: dict[str, DeliveryCustomization] = {} 

112 

113 delivery_data = action_data.get(ATTR_DELIVERY) 

114 if isinstance(delivery_data, list): 

115 # a bare list of deliveries implies intent to restrict 

116 _LOGGER.debug("SUPERNOTIFY defaulting delivery selection as explicit for list %s", delivery_data) 

117 if self.delivery_selection is None: 

118 self.delivery_selection = DELIVERY_SELECTION_EXPLICIT 

119 self.delivery_overrides = {k: DeliveryCustomization({}) for k in action_data.get(ATTR_DELIVERY, [])} 

120 elif isinstance(delivery_data, str) and delivery_data: 

121 # a bare list of deliveries implies intent to restrict 

122 _LOGGER.debug("SUPERNOTIFY defaulting delivery selection as explicit for single %s", delivery_data) 

123 if self.delivery_selection is None: 

124 self.delivery_selection = DELIVERY_SELECTION_EXPLICIT 

125 self.delivery_overrides = {delivery_data: DeliveryCustomization({})} 

126 elif isinstance(delivery_data, dict): 

127 # whereas a dict may be used to tune or restrict 

128 if self.delivery_selection is None: 

129 self.delivery_selection = DELIVERY_SELECTION_IMPLICIT 

130 _LOGGER.debug("SUPERNOTIFY defaulting delivery selection as implicit for mapping %s", delivery_data) 

131 self.delivery_overrides = {k: DeliveryCustomization(v) for k, v in action_data.get(ATTR_DELIVERY, {}).items()} 

132 elif delivery_data: 

133 _LOGGER.warning("SUPERNOTIFY Unable to interpret delivery data %s", delivery_data) 

134 if self.delivery_selection is None: 

135 self.delivery_selection = DELIVERY_SELECTION_IMPLICIT 

136 else: 

137 if self.delivery_selection is None: 

138 self.delivery_selection = DELIVERY_SELECTION_IMPLICIT 

139 

140 self.action_groups: list[str] | None = nullable_ensure_list(action_data.get(ATTR_ACTION_GROUPS)) 

141 self.recipients_override: list[str] | None = nullable_ensure_list(action_data.get(ATTR_RECIPIENTS)) 

142 self.extra_data.update(action_data.get(ATTR_DATA, {})) 

143 self.media: dict[str, Any] = action_data.get(ATTR_MEDIA) or {} 

144 self.debug: bool = action_data.get(ATTR_DEBUG, False) 

145 self.actions: list[dict[str, Any]] = ensure_list(action_data.get(ATTR_ACTIONS)) 

146 

147 self.selected_deliveries: dict[str, dict[str, Any]] = {} 

148 self.enabled_scenarios: dict[str, Scenario] = {} 

149 self.selected_scenario_names: list[str] = [] 

150 self._suppression_reason: SuppressionReason | None = None 

151 self._delivery_error: list[str] | None = None 

152 self.condition_variables: ConditionVariables 

153 

154 async def initialize(self) -> None: 

155 """Async post-construction initialization""" 

156 self.occupancy: dict[str, list[Recipient]] = self.people_registry.determine_occupancy() 

157 self.condition_variables = ConditionVariables( 

158 self.applied_scenario_names, 

159 self.required_scenario_names, 

160 self.constrain_scenario_names, 

161 self.priority, 

162 self.occupancy, 

163 self.message, 

164 self._title, 

165 ) # requires occupancy first 

166 

167 enabled_scenario_names: list[str] = list(self.applied_scenario_names) or [] 

168 self.selected_scenario_names = await self.select_scenarios() 

169 enabled_scenario_names.extend(self.selected_scenario_names) 

170 if self.constrain_scenario_names: 

171 enabled_scenario_names = [ 

172 s for s in enabled_scenario_names if (s in self.constrain_scenario_names or s in self.applied_scenario_names) 

173 ] 

174 if self.required_scenario_names and not any(s in enabled_scenario_names for s in self.required_scenario_names): 

175 _LOGGER.info("SUPERNOTIFY suppressing notification, no required scenarios enabled") 

176 self.selected_deliveries = {} 

177 self.suppress(SuppressionReason.NO_SCENARIO) 

178 else: 

179 for s in enabled_scenario_names: 

180 scenario_obj = self.context.scenario_registry.scenarios.get(s) 

181 if scenario_obj is not None: 

182 self.enabled_scenarios[s] = scenario_obj 

183 

184 self.selected_deliveries = self.select_deliveries() 

185 if self.context.snoozer.is_global_snooze(self.priority): 

186 self.suppress(SuppressionReason.SNOOZED) 

187 self.apply_enabled_scenarios() 

188 

189 if not self.media: 

190 self.media = self.media_requirements(self.extra_data) 

191 

192 def media_requirements(self, data: dict[str, Any]) -> dict[str, Any]: 

193 """If no media defined, look for iOS / Android actions that have media defined 

194 

195 Example is the Frigate blueprint, which generates `image`, `video` etc 

196 in the `data` section, that can also be used for email attachments 

197 """ 

198 media_dict = {} 

199 if not data: 

200 return {} 

201 if data.get("image"): 

202 media_dict[ATTR_MEDIA_SNAPSHOT_URL] = data.get("image") 

203 if data.get("video"): 

204 media_dict[ATTR_MEDIA_CLIP_URL] = data.get("video") 

205 if data.get("attachment", {}).get("url"): 

206 url = data["attachment"]["url"] 

207 if url and url.endswith(".mp4") and not media_dict.get(ATTR_MEDIA_CLIP_URL): 

208 media_dict[ATTR_MEDIA_CLIP_URL] = url 

209 elif ( 

210 url 

211 and (url.endswith(".jpg") or url.endswith(".jpeg") or url.endswith(".png")) 

212 and not media_dict.get(ATTR_MEDIA_SNAPSHOT_URL) 

213 ): 

214 media_dict[ATTR_MEDIA_SNAPSHOT_URL] = url 

215 return media_dict 

216 

217 def validate_action_data(self, action_data: dict[str, Any]) -> None: 

218 if action_data.get(ATTR_PRIORITY) and action_data.get(ATTR_PRIORITY) not in PRIORITY_VALUES: 

219 _LOGGER.info("SUPERNOTIFY custom priority %s", action_data.get(ATTR_PRIORITY)) 

220 try: 

221 humanize.validate_with_humanized_errors(action_data, ACTION_DATA_SCHEMA) 

222 except vol.Invalid as e: 

223 _LOGGER.warning("SUPERNOTIFY invalid service data %s: %s", action_data, e) 

224 raise 

225 

226 def apply_enabled_scenarios(self) -> None: 

227 """Set media and action_groups from scenario if defined, first come first applied""" 

228 action_groups: list[str] = [] 

229 for scenario in self.enabled_scenarios.values(): 

230 if scenario.media: 

231 if self.media: 

232 self.media.update(scenario.media) 

233 else: 

234 self.media = scenario.media 

235 if scenario.action_groups: 

236 action_groups.extend(ag for ag in scenario.action_groups if ag not in action_groups) 

237 # self.action_groups only accessed from inside Envelope 

238 if self.action_groups: 

239 self.action_groups.extend(action_groups) 

240 else: 

241 self.action_groups = action_groups 

242 

243 def select_deliveries(self) -> dict[str, dict[str, Any]]: 

244 scenario_enable_deliveries: list[str] = [] 

245 scenario_disable_deliveries: list[str] = [] 

246 default_enable_deliveries: list[str] = [] 

247 recipients_enable_deliveries: list[str] = [] 

248 

249 if self.delivery_selection != DELIVERY_SELECTION_FIXED: 

250 for scenario in self.enabled_scenarios.values(): 

251 scenario_enable_deliveries.extend(scenario.enabling_deliveries()) 

252 for scenario in self.enabled_scenarios.values(): 

253 scenario_disable_deliveries.extend(scenario.disabling_deliveries()) 

254 

255 scenario_enable_deliveries = list(set(scenario_enable_deliveries)) 

256 scenario_disable_deliveries = list(set(scenario_disable_deliveries)) 

257 

258 for recipient in self.all_recipients(): 

259 recipients_enable_deliveries.extend(recipient.enabling_delivery_names()) 

260 if self.delivery_selection == DELIVERY_SELECTION_IMPLICIT: 

261 # all deliveries with SELECTION_DEFAULT in CONF_SELECTION 

262 default_enable_deliveries = [d.name for d in self.context.delivery_registry.implicit_deliveries] 

263 

264 self.debug_trace.record_delivery_selection("scenario_enable_deliveries", scenario_enable_deliveries) 

265 self.debug_trace.record_delivery_selection("scenario_disable_deliveries", scenario_disable_deliveries) 

266 self.debug_trace.record_delivery_selection("default_enable_deliveries", default_enable_deliveries) 

267 self.debug_trace.record_delivery_selection("recipient_enable_deliveries", recipients_enable_deliveries) 

268 

269 override_enable_deliveries: list[str] = [] 

270 override_disable_deliveries: list[str] = [] 

271 

272 # apply the deliveries defined in the notification action call 

273 for delivery, delivery_override in self.delivery_overrides.items(): 

274 if ( 

275 (delivery_override is None or delivery_override.enabled is True) 

276 and delivery in self.context.delivery_registry.enabled_deliveries 

277 ) or ( 

278 (delivery_override is not None and delivery_override.enabled is True) 

279 and delivery in self.context.delivery_registry.disabled_deliveries 

280 ): 

281 override_enable_deliveries.append(delivery) 

282 elif delivery_override is not None and delivery_override.enabled is False: 

283 override_disable_deliveries.append(delivery) 

284 

285 # if self.delivery_selection != DELIVERY_SELECTION_FIXED: 

286 # scenario_disable_deliveries = [ 

287 # d.name 

288 # for d in self.context.delivery_registry.deliveries.values() 

289 # if d.selection == [SELECTION_BY_SCENARIO] 

290 # and d.name not in scenario_enable_deliveries 

291 # and (d.name not in override_enable_deliveries or self.delivery_selection != DELIVERY_SELECTION_EXPLICIT) 

292 # ] 

293 all_global_enabled: list[str] = list( 

294 set(scenario_enable_deliveries + default_enable_deliveries + override_enable_deliveries) 

295 ) 

296 all_enabled: list[str] = all_global_enabled + recipients_enable_deliveries 

297 all_disabled: list[str] = scenario_disable_deliveries + override_disable_deliveries 

298 override_enabled: list[str] = list(set(scenario_enable_deliveries + override_enable_deliveries)) 

299 self.debug_trace.record_delivery_selection("override_disable_deliveries", override_disable_deliveries) 

300 self.debug_trace.record_delivery_selection("override_enable_deliveries", override_enable_deliveries) 

301 

302 unsorted_maybe_objs: list[Delivery | None] = [ 

303 self.delivery_registry.deliveries.get(d) for d in all_enabled if d not in all_disabled 

304 ] 

305 unsorted_objs: list[Delivery] = [ 

306 d for d in unsorted_maybe_objs if d is not None and (d.enabled or d.name in override_enabled) 

307 ] 

308 first: list[str] = [d.name for d in unsorted_objs if d.selection_rank == SelectionRank.FIRST] 

309 anywhere: list[str] = [d.name for d in unsorted_objs if d.selection_rank == SelectionRank.ANY] 

310 last: list[str] = [d.name for d in unsorted_objs if d.selection_rank == SelectionRank.LAST] 

311 selected = first + anywhere + last 

312 self.debug_trace.record_delivery_selection("ranked", selected) 

313 

314 # TODO: clean up this ugly logic, reorganize delivery around people 

315 results: dict[str, dict[str, Any]] = {d: {} for d in selected} 

316 personal_deliveries = [d for d in selected if d not in all_global_enabled and d in recipients_enable_deliveries] 

317 for personal_delivery in personal_deliveries: 

318 results[personal_delivery].setdefault("recipients", []) 

319 for recipient in self.all_recipients(): 

320 if personal_delivery in recipient.enabling_delivery_names(): 

321 results[personal_delivery]["recipients"].append(recipient.entity_id) 

322 return results 

323 

324 def suppress(self, reason: SuppressionReason) -> None: 

325 self._suppression_reason = reason 

326 if reason not in self._skip_reasons: 

327 self._skip_reasons.append(reason) 

328 _LOGGER.info(f"SUPERNOTIFY Suppressing notification, reason:{reason}, id:{self.id}") 

329 

330 async def deliver(self) -> bool: 

331 _LOGGER.debug( 

332 "Message: %s, notification: %s, deliveries: %s", 

333 self.message, 

334 self.id, 

335 self.selected_deliveries, 

336 ) 

337 

338 for delivery_name, details in self.selected_deliveries.items(): 

339 self.deliveries[delivery_name] = {} 

340 delivery = self.context.delivery_registry.deliveries.get(delivery_name) 

341 if self._suppression_reason is not None: 

342 _LOGGER.info("SUPERNOTIFY Suppressing globally silenced/snoozed notification (%s)", self.id) 

343 self.record_result(delivery, suppression_reason=SuppressionReason.SNOOZED) 

344 elif delivery: 

345 await self.call_transport(delivery, recipients=details.get("recipients")) 

346 else: 

347 _LOGGER.error(f"SUPERNOTIFY Unexpected missing delivery {delivery_name}") 

348 

349 if self.delivered == 0 and not self._suppression_reason: 

350 if self.failed == 0 and not self.dupe: 

351 for delivery in self.context.delivery_registry.fallback_by_default_deliveries: 

352 if delivery.name not in self.selected_deliveries: 

353 await self.call_transport(delivery) 

354 

355 if self.failed > 0: 

356 for delivery in self.context.delivery_registry.fallback_on_error_deliveries: 

357 if delivery.name not in self.selected_deliveries: 

358 await self.call_transport(delivery) 

359 

360 return self.delivered > 0 

361 

362 async def call_transport(self, delivery: Delivery, recipients: list[str] | None = None) -> None: 

363 try: 

364 transport: Transport = delivery.transport 

365 if not transport.enabled: 

366 self.record_result(delivery, suppression_reason=SuppressionReason.TRANSPORT_DISABLED) 

367 _LOGGER.debug("SUPERNOTIFY Skipping delivery %s based on transport disabled", delivery) 

368 return 

369 

370 delivery_priorities: list[str] = delivery.priority 

371 if self.priority and delivery_priorities and self.priority not in delivery_priorities: 

372 _LOGGER.debug("SUPERNOTIFY Skipping delivery %s based on priority (%s)", delivery, self.priority) 

373 self.record_result(delivery, suppression_reason=SuppressionReason.PRIORITY) 

374 return 

375 if not delivery.evaluate_conditions(self.condition_variables): 

376 _LOGGER.debug("SUPERNOTIFY Skipping delivery %s based on conditions", delivery) 

377 self.record_result(delivery, suppression_reason=SuppressionReason.DELIVERY_CONDITION) 

378 return 

379 

380 targets: list[Target] = self.generate_targets(delivery, recipients=recipients) 

381 envelopes: list[Envelope] = self.generate_envelopes(delivery, targets) 

382 if not envelopes: 

383 if delivery.target_required == TargetRequired.ALWAYS and ( 

384 not targets or not any(t.has_resolved_target() for t in targets) 

385 ): 

386 reason: SuppressionReason = SuppressionReason.NO_TARGET 

387 else: 

388 reason = SuppressionReason.UNKNOWN 

389 self.record_result(delivery, targets=targets, suppression_reason=reason) 

390 

391 for envelope in envelopes: 

392 if self.context.dupe_checker.check(envelope): 

393 _LOGGER.debug("SUPERNOTIFY Suppressing dupe envelope, %s", self.message) 

394 self.record_result(delivery, envelope, suppression_reason=SuppressionReason.DUPE) 

395 continue 

396 try: 

397 if not await transport.deliver(envelope, debug_trace=self.debug_trace): 

398 _LOGGER.debug("SUPERNOTIFY No delivery for %s", delivery.name) 

399 self.record_result(delivery, envelope) 

400 except Exception as e2: 

401 _LOGGER.exception("SUPERNOTIFY Failed to deliver %s: %s", delivery.name, e2) 

402 envelope.error_count = envelope.error_count + 1 

403 transport.record_error(str(e2), method="deliver") 

404 envelope.delivery_error = format_exception(e2) 

405 self.record_result(delivery, envelope) 

406 

407 except Exception as e: 

408 _LOGGER.exception("SUPERNOTIFY Failed to notify using %s", delivery.name) 

409 _LOGGER.debug("SUPERNOTIFY %s delivery failure", delivery, exc_info=True) 

410 self.deliveries.setdefault(delivery.name, {}) 

411 self.deliveries[delivery.name].setdefault("errors", []) 

412 errors: list[str] = cast("list[str]", self.deliveries[delivery.name]["errors"]) 

413 errors.append("\n".join(format_exception(e))) 

414 

415 def record_result( 

416 self, 

417 delivery: Delivery | None, 

418 envelope: Envelope | None = None, 

419 targets: list[Target] | None = None, 

420 suppression_reason: SuppressionReason | None = None, 

421 ) -> None: 

422 """Debugging (and unit test) support for notifications that failed or were skipped""" 

423 if delivery: 

424 if envelope: 

425 self.delivered += envelope.delivered 

426 self.error_count += envelope.error_count 

427 self.deliveries.setdefault(delivery.name, {}) 

428 if envelope.delivered: 

429 self.deliveries[delivery.name].setdefault(KEY_DELIVERED, []) 

430 self.deliveries[delivery.name][KEY_DELIVERED].append(envelope) # type: ignore 

431 else: 

432 if suppression_reason: 

433 envelope.skip_reason = suppression_reason 

434 if suppression_reason not in self._skip_reasons: 

435 self._skip_reasons.append(suppression_reason) 

436 if suppression_reason == SuppressionReason.DUPE: 

437 self.dupe = True 

438 if envelope.error_count: 

439 self.deliveries[delivery.name].setdefault(KEY_FAILED, []) 

440 self.deliveries[delivery.name][KEY_FAILED].append(envelope) # type: ignore 

441 self.failed += 1 

442 else: 

443 self.deliveries[delivery.name].setdefault(KEY_SUPPRESSED, []) 

444 self.deliveries[delivery.name][KEY_SUPPRESSED].append(envelope) # type: ignore 

445 self.suppressed += 1 

446 

447 if not envelope: 

448 delivery_name: str = delivery.name if delivery else "!UNKNOWN!" 

449 skip_summary: dict[str, Any] = { 

450 "target_required": delivery.target_required if delivery else "!UNKNOWN!", 

451 "suppression_reason": str(suppression_reason), 

452 } 

453 self.deliveries.setdefault(delivery_name, {}) 

454 if targets: 

455 skip_summary["targets"] = targets 

456 self.deliveries[delivery_name][KEY_SKIPPED] = skip_summary 

457 self.skipped += 1 

458 

459 def contents(self, minimal: bool = False, **_kwargs: Any) -> dict[str, Any]: 

460 """ArchiveableObject implementation""" 

461 object_refs = ["context", "people_registry", "delivery_registry"] 

462 keys_only = ["enabled_scenarios"] 

463 debug_only = ["debug_trace"] 

464 exposed_if_populated = ["_delivery_error", "message_html", "extra_data", "actions", "_suppression_reason"] 

465 # fine tune dict order to ease the eye-burden when reviewing archived notifications 

466 preferred_order = [ 

467 "id", 

468 "created", 

469 "message", 

470 "applied_scenario_names", 

471 "constrain_scenario_names", 

472 "required_scenario_names", 

473 "enabled_scenarios", 

474 "selected_scenario_names", 

475 "delivery_selection", 

476 "delivery_overrides", 

477 "delivery_selection", 

478 "selected_deliveries", 

479 "recipients_override", 

480 "delivered", 

481 "failed", 

482 "suppressed", 

483 "skipped", 

484 "error_count", 

485 "deliveries", 

486 ] 

487 # preferred fields 

488 result = { 

489 k: sanitize( 

490 self.__dict__[k], minimal=minimal, occupancy_only=True, top_level_keys_only=(minimal and k in keys_only) 

491 ) 

492 for k in preferred_order 

493 } 

494 # all the rest not explicitly excluded 

495 result.update({ 

496 k: sanitize(v, minimal=minimal, occupancy_only=True) 

497 for k, v in self.__dict__.items() 

498 if k not in result 

499 and k not in exposed_if_populated 

500 and k not in object_refs 

501 and not k.startswith("_") 

502 and (not minimal or k not in keys_only) 

503 and (not minimal or k not in debug_only) 

504 }) 

505 # the exposed only if populated fields 

506 result.update({ 

507 k: sanitize(self.__dict__[k], minimal=minimal, occupancy_only=True) 

508 for k in exposed_if_populated 

509 if self.__dict__.get(k) 

510 }) 

511 return result 

512 

513 def base_filename(self) -> str: 

514 """ArchiveableObject implementation""" 

515 return f"{self.created.isoformat()[:16]}_{self.id}" 

516 

517 def delivery_data(self, delivery_name: str) -> dict[str, Any]: 

518 delivery_override: DeliveryCustomization | None = self.delivery_overrides.get(delivery_name) 

519 return delivery_override.data if delivery_override and delivery_override.data else {} 

520 

521 @property 

522 def delivered_envelopes(self) -> list[Envelope]: 

523 result: list[Envelope] = [] 

524 for delivery_result in self.deliveries.values(): 

525 result.extend(cast("list[Envelope]", delivery_result.get(KEY_DELIVERED, []))) 

526 return result 

527 

528 @property 

529 def undelivered_envelopes(self) -> list[Envelope]: 

530 result: list[Envelope] = [] 

531 for delivery_result in self.deliveries.values(): 

532 result.extend(cast("list[Envelope]", delivery_result.get(KEY_SUPPRESSED, []))) 

533 result.extend(cast("list[Envelope]", delivery_result.get(KEY_FAILED, []))) 

534 return result 

535 

536 async def select_scenarios(self) -> list[str]: 

537 return [s.name for s in self.context.scenario_registry.scenarios.values() if s.evaluate(self.condition_variables)] 

538 

539 def generate_targets(self, delivery: Delivery, recipients: list[str] | None = None) -> list[Target]: 

540 

541 if delivery.target_required == TargetRequired.NEVER: 

542 # don't waste time computing targets for deliveries that don't need them 

543 return [Target(None, target_data=delivery.data)] 

544 

545 computed_target: Target 

546 

547 if delivery.target_usage == TARGET_USE_FIXED: 

548 if delivery.target: 

549 computed_target = delivery.target.safe_copy() 

550 self.debug_trace.record_target(delivery.name, "100_delivery_default_fixed", computed_target) 

551 else: 

552 computed_target = Target(None, target_data=delivery.data) 

553 self.debug_trace.record_target(delivery.name, "101_delivery_default_fixed_empty", computed_target) 

554 elif recipients is not None: 

555 computed_target = Target(recipients) 

556 self.debug_trace.record_target(delivery.name, "102_delivery_default_fixed", computed_target) 

557 

558 elif not self._target: 

559 # Unless there are explicit targets, include everyone on the people registry 

560 computed_target = self.default_person_ids(delivery) 

561 self.debug_trace.record_target(delivery.name, "201_no_action_target", computed_target) 

562 else: 

563 computed_target = self._target.safe_copy() 

564 self.debug_trace.record_target(delivery.name, "202_action_target", computed_target) 

565 

566 # 1st round of filtering for snooze and resolving people->direct targets 

567 computed_target = self.context.snoozer.filter_recipients(computed_target, self.priority, delivery) 

568 self.debug_trace.record_target(delivery.name, "300_post_snooze", computed_target) 

569 # turn person_ids into emails and phone numbers 

570 for indirect_target in self.resolve_indirect_targets(computed_target, delivery): 

571 computed_target += indirect_target 

572 self.debug_trace.record_target(delivery.name, "310_resolve_indirect", computed_target) 

573 computed_target += self.resolve_scenario_targets(delivery) 

574 self.debug_trace.record_target(delivery.name, "320_resolved_scenario_targets", computed_target) 

575 # filter out target not required for this delivery 

576 computed_target = delivery.select_targets(computed_target) 

577 self.debug_trace.record_target(delivery.name, "330_delivery_selection", computed_target) 

578 primary_count = len(computed_target) 

579 

580 if delivery.target_usage == TARGET_USE_ON_NO_DELIVERY_TARGETS: 

581 if not computed_target.has_targets() and delivery.target: 

582 computed_target += delivery.target 

583 self.debug_trace.record_target(delivery.name, "400_delivery_default_no_delivery_targets", computed_target) 

584 elif delivery.target_usage == TARGET_USE_ON_NO_ACTION_TARGETS: 

585 if not self._target and delivery.target: 

586 computed_target += delivery.target 

587 self.debug_trace.record_target(delivery.name, "401_delivery_default_no_action_targets", computed_target) 

588 elif delivery.target_usage == TARGET_USE_MERGE_ON_DELIVERY_TARGETS: 

589 # merge in the delivery defaults if there's a target defined in action call 

590 if computed_target.has_targets() and delivery.target: 

591 computed_target += delivery.target 

592 self.debug_trace.record_target(delivery.name, "402_delivery_merge_on_delivery_targets", computed_target) 

593 elif delivery.target_usage == TARGET_USE_MERGE_ALWAYS: 

594 # merge in the delivery defaults even if there's not a target defined in action call 

595 if delivery.target: 

596 computed_target += delivery.target 

597 self.debug_trace.record_target(delivery.name, "403_delivery_merge_always_targets", computed_target) 

598 elif delivery.target_usage == TARGET_USE_FIXED: 

599 _LOGGER.debug("SUPERNOTIFY Fixed target on delivery %s", delivery.name) 

600 self.debug_trace.record_target(delivery.name, "404_fixed_target", computed_target) 

601 else: 

602 self.debug_trace.record_target(delivery.name, "405_no_target_usage_match", computed_target) 

603 _LOGGER.debug("SUPERNOTIFY No useful target definition for delivery %s", delivery.name) 

604 

605 if len(computed_target) > primary_count: 

606 _LOGGER.debug( 

607 "SUPERNOTIFY Delivery config added %s targets for %s", len(computed_target) - primary_count, delivery.name 

608 ) 

609 

610 # 2nd round of filtering for snooze and resolving people->direct targets after delivery target applied 

611 computed_target = self.context.snoozer.filter_recipients(computed_target, self.priority, delivery) 

612 self.debug_trace.record_target(delivery.name, "501_post_snooze", computed_target) 

613 for indirect_target in self.resolve_indirect_targets(computed_target, delivery): 

614 computed_target += indirect_target 

615 self.debug_trace.record_target(delivery.name, "502_resolved_indirect_targets", computed_target) 

616 computed_target += self.resolve_scenario_targets(delivery) 

617 self.debug_trace.record_target(delivery.name, "503_resolved_scenario_targets", computed_target) 

618 computed_target = delivery.select_targets(computed_target) 

619 self.debug_trace.record_target(delivery.name, "504_delivery_selection", computed_target) 

620 

621 split_targets: list[Target] = computed_target.split_by_target_data() 

622 self.debug_trace.record_target(delivery.name, "610_delivery_split_targets", split_targets) 

623 

624 direct_targets: list[Target] = [t.direct() for t in split_targets] 

625 self.debug_trace.record_target(delivery.name, "620_narrow_to_direct", direct_targets) 

626 

627 if delivery.options.get(OPTION_UNIQUE_TARGETS, False): 

628 direct_targets = [t - self._already_selected for t in direct_targets] 

629 self.debug_trace.record_target(delivery.name, "630_make_unique_across_deliveries", direct_targets) 

630 for direct_target in direct_targets: 

631 self._already_selected += direct_target 

632 self.debug_trace.record_target(delivery.name, "999_final_cut", direct_targets) 

633 return direct_targets 

634 

635 def resolve_scenario_targets(self, delivery: Delivery) -> Target: 

636 resolved: Target = Target() 

637 for scenario in self.enabled_scenarios.values(): 

638 customization: DeliveryCustomization | None = scenario.delivery_customization(delivery.name) 

639 if customization and customization.target and customization.target.has_targets(): 

640 resolved += customization.target 

641 return resolved 

642 

643 def all_recipients(self) -> list[Recipient]: 

644 recipients: list[Recipient] = [] 

645 if self._target: 

646 # explicit targets given 

647 recipients.extend( 

648 self.people_registry.people[pers_ent_id] 

649 for pers_ent_id in self._target.person_ids 

650 if pers_ent_id in self.people_registry.people and self.people_registry.people[pers_ent_id].enabled 

651 ) 

652 else: 

653 # default to all known recipients 

654 recipients = self.people_registry.enabled_recipients() 

655 recipients = [r for r in recipients if self.recipients_override is None or r.entity_id in self.recipients_override] 

656 return recipients 

657 

658 def default_person_ids(self, delivery: Delivery) -> Target: 

659 # If target not specified on service call or delivery, then default to std list of recipients 

660 people: list[Recipient] = self.people_registry.filter_recipients_by_occupancy(delivery.occupancy) 

661 people = [p for p in people if self.recipients_override is None or p.entity_id in self.recipients_override] 

662 return Target({ATTR_PERSON_ID: [p.entity_id for p in people if p.entity_id]}) 

663 

664 def resolve_indirect_targets(self, target: Target, delivery: Delivery) -> list[Target]: 

665 # enrich data selected in configuration for this delivery, from direct target definition or attrs like email or phone 

666 resolved: Target = Target() 

667 additional: list[Target] = [] 

668 

669 for person_id in target.person_ids: 

670 recipient: Recipient | None = self.people_registry.people.get(person_id) 

671 if recipient and recipient.enabled: 

672 recipient_target = recipient.target(delivery.name) 

673 if recipient_target.target_specific_data: 

674 additional.append(recipient_target) 

675 else: 

676 resolved += recipient_target 

677 else: 

678 _LOGGER.debug("SUPERNOTIFY Skipping recipient %s with enabled switched off", person_id) 

679 

680 return [resolved, *additional] 

681 

682 def generate_envelopes(self, delivery: Delivery, targets: list[Target]) -> list[Envelope]: 

683 # now the list of recipients determined, resolve this to target addresses or entities 

684 

685 envelopes: list[Envelope] = [] 

686 for target in targets: 

687 # a target is always generated, even if there are no recipients 

688 if target.has_resolved_target() or delivery.target_required != TargetRequired.ALWAYS: 

689 envelope_data = {} 

690 envelope_data.update(delivery.data) 

691 envelope_data.update(self.extra_data) # action call data 

692 if target.target_data: 

693 envelope_data.update(target.target_data) 

694 # scenario applied at cross-delivery level in apply_enabled_scenarios 

695 for scenario in self.enabled_scenarios.values(): 

696 customization: DeliveryCustomization | None = scenario.delivery_customization(delivery.name) 

697 if customization and customization.data: 

698 envelope_data.update(customization.data) 

699 envelopes.append(Envelope(delivery, self, target, envelope_data, context=self.context)) 

700 

701 return envelopes