Coverage for functions \ flipdare \ wrapper \ dare_wrapper.py: 79%

315 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-05-08 12:22 +1000

1#!/usr/bin/env python 

2# Copyright (c) 2026 Flipdare Pty Ltd. All rights reserved. 

3# 

4# This file is part of Flipdare's proprietary software and contains 

5# confidential and copyrighted material. Unauthorised copying, 

6# modification, distribution, or use of this file is strictly 

7# prohibited without prior written permission from Flipdare Pty Ltd. 

8# 

9# This software includes third-party components licensed under MIT, 

10# BSD, and Apache 2.0 licences. See THIRD_PARTY_NOTICES for details. 

11# 

12 

13from __future__ import annotations 

14 

15from enum import Enum 

16from typing import Any 

17 

18from flipdare.app_log import LOG 

19from flipdare.constants import IS_DEBUG, VOTING_DURATION 

20from flipdare.generated.model.dare_model import DareKeys, DareModel 

21from flipdare.generated.model.internal.dare_event_model import DareEventModel 

22from flipdare.generated.model.internal.stopwatch_model import StopwatchModel 

23from flipdare.generated.model.internal.video_model import VideoModel 

24from flipdare.generated.model.internal.view_stats_model import ViewStatsModel 

25from flipdare.generated.model.pledge_stats_model import PledgeStatsModel 

26from flipdare.generated.shared.model.app_visibility import AppVisibility 

27from flipdare.generated.shared.model.dare.ballot_algorithm_type import BallotAlgorithmType 

28from flipdare.generated.shared.model.dare.ballot_result import BallotResult 

29from flipdare.generated.shared.model.dare.dare_status import DareStatus 

30from flipdare.generated.shared.model.issue.disputed_progress import DisputedProgress 

31from flipdare.generated.shared.model.issue.issue_progress import IssueProgress 

32from flipdare.generated.shared.model.restriction.moderation_decision import ModerationDecision 

33from flipdare.voting.ballot import BallotOutcome 

34from flipdare.wrapper._persisted_wrapper import PersistedWrapper 

35 

36__all__ = ["DareWrapper"] 

37 

38_K = DareKeys 

39 

40 

41class DareWrapper(PersistedWrapper[DareModel]): 

42 MODEL_CLASS = DareModel 

43 

44 @property 

45 def status(self) -> DareStatus: 

46 return self.model.status 

47 

48 @property 

49 def pre_flag_status(self) -> DareStatus | None: 

50 return self.model.pre_flag_status 

51 

52 @property 

53 def processing_complete(self) -> bool: 

54 # NOTE: we check all fields except Standalone, because there 

55 # NOTE: are not related to initial processing. 

56 is_complete = ( 

57 self.thumbnail_created 

58 and self.hash_created 

59 and self.optimized_video 

60 and self.search_indexed 

61 ) 

62 

63 if is_complete and not self.processed: 

64 msg = f"Dare {self.obj_id} steps complete, but processed is stale, Setting to True." 

65 LOG().warning(msg) 

66 self.processed = True 

67 

68 return is_complete 

69 

70 @property 

71 def flag_id(self) -> str | None: 

72 return self.model.flag_id 

73 

74 @property 

75 def can_share(self) -> bool: 

76 return self.visibility == AppVisibility.PUBLIC and not self.flagged 

77 

78 def searchable_values(self) -> list[str]: 

79 return self.model.searchable_values 

80 

81 @property 

82 def short_description(self) -> str: 

83 return self.model.short_description 

84 

85 @property 

86 def short_description_with_from(self) -> str: 

87 return self.model.short_description_with_from 

88 

89 @property 

90 def human_readable_id(self) -> str: 

91 return self.model.human_readable_id 

92 

93 @property 

94 def disputed_progress(self) -> DisputedProgress | None: 

95 return self.model.disputed_progress 

96 

97 @property 

98 def moderation_decision(self) -> ModerationDecision | None: 

99 return self.model.moderation_decision 

100 

101 @moderation_decision.setter 

102 def moderation_decision(self, value: ModerationDecision) -> None: 

103 self._dare_update(DareKeys.MODERATION_DECISION, value) 

104 

105 # cant set flagged yet, instead set to review_required 

106 # if moderation_decision is not approved 

107 

108 obj_id = self.model.obj_id 

109 old_status: DareStatus = self.model.status 

110 status = old_status 

111 set_pre_flag: bool = False 

112 

113 match value: 

114 case ( 

115 ModerationDecision.AUTO_APPROVE_REPUTATION 

116 | ModerationDecision.AUTO_APPROVE_SENTIMENT 

117 ): 

118 if old_status != DareStatus.OPEN: 

119 # whether submitted or re-submitted, set to open 

120 status = DareStatus.OPEN 

121 case ( 

122 ModerationDecision.AUTO_REJECT_REPUTATION 

123 | ModerationDecision.AUTO_REJECT_SENTIMENT 

124 | ModerationDecision.REVIEW_REQUIRED 

125 ): 

126 if old_status != DareStatus.REVIEW_REQUIRED: 

127 # need a review when auto-rejecting, and also when moderator flags for review 

128 status = DareStatus.REVIEW_REQUIRED 

129 set_pre_flag = True 

130 case _: 

131 LOG().warning(f"Unhandled moderation decision {value} for dare {obj_id}") 

132 

133 if status == old_status: 

134 msg = f"No status change for dare {obj_id} since for moderation {value}" 

135 LOG().debug(msg) 

136 return 

137 

138 if IS_DEBUG: 

139 msg = ( 

140 f"Updating status for dare {obj_id} from {old_status} to {status} " 

141 f"for moderation {value}" 

142 ) 

143 LOG().debug(msg) 

144 if set_pre_flag: 

145 self._dare_update(_K.PRE_FLAG_STATUS, old_status) 

146 self._dare_update(_K.STATUS, status) 

147 

148 @property 

149 def flagged(self) -> bool: 

150 return self.status == DareStatus.FLAGGED 

151 

152 @property 

153 def has_voting_started(self) -> bool: 

154 return self.model.voting_timer is not None 

155 

156 @property 

157 def ballot_result(self) -> BallotResult | None: 

158 return self.model.ballot_result 

159 

160 @property 

161 def voting_timer(self) -> StopwatchModel | None: 

162 return self.model.voting_timer 

163 

164 def start_vote_timer(self) -> None: 

165 if self.voting_timer is not None: 

166 msg = f"Dare {self.obj_id} already has a voting timer, skipping start" 

167 LOG().warning(msg) 

168 return 

169 

170 voting_timer = StopwatchModel.from_now(VOTING_DURATION) 

171 self._dare_update(_K.VOTING_TIMER, voting_timer) 

172 self._dare_update(_K.STATUS, DareStatus.VOTING) 

173 

174 @property 

175 def within_voting_period(self) -> bool: 

176 timer = self.voting_timer 

177 if timer is None: 

178 return True # timer has not been started . 

179 

180 return not timer.is_expired 

181 

182 def set_vote_result( 

183 self, 

184 outcome: BallotOutcome, 

185 ) -> None: 

186 old_timer = self.model.voting_timer 

187 if old_timer is None: 

188 if IS_DEBUG: 

189 msg = f"Dare {self.obj_id} has no existing voting timer, creating new one with duration {VOTING_DURATION}" 

190 LOG().debug(msg) 

191 

192 voting_timer = StopwatchModel.from_now(VOTING_DURATION) 

193 self._dare_update(_K.VOTING_TIMER, voting_timer) 

194 

195 ballot_result = outcome.result 

196 if ballot_result.not_enough_votes: 

197 # the voting algorithm controls the timer, 

198 if IS_DEBUG: 

199 msg = f"Dare {self.obj_id} doesn't have enough votes, setting ballot result to {ballot_result}" 

200 LOG().debug(msg) 

201 self._dare_update(_K.BALLOT_RESULT, ballot_result) 

202 return 

203 

204 algorithm = outcome.algorithm 

205 if algorithm is None: 

206 # we must have an algorithm to set a result 

207 msg = f"Dare {self.obj_id} has a ballot result of {ballot_result} but no algorithm, cannot set result" 

208 LOG().warning(msg) 

209 return 

210 

211 new_dare_status: DareStatus 

212 match ballot_result: 

213 case BallotResult.ACCEPTED | BallotResult.AUTO_ACCEPTED: 

214 new_dare_status = DareStatus.PAY_OUT 

215 case ( 

216 BallotResult.REJECTED 

217 | BallotResult.AUTO_REJECTED 

218 | BallotResult.TIE 

219 | BallotResult.EXPIRED 

220 ): 

221 new_dare_status = DareStatus.FINALIZED 

222 case _: 

223 # this should never happen, since we check not_enough_votes above, but just in case 

224 msg = f"Dare {self.obj_id} has an unexpected ballot result of {ballot_result}, cannot determine new dare status" 

225 LOG().warning(msg) 

226 return 

227 

228 if IS_DEBUG: 

229 msg = ( 

230 f"Setting vote result for dare {self.doc_id}: " 

231 f"status update to {new_dare_status}, voting timer: {old_timer}, " 

232 f"decision: {ballot_result}, algorithm: {algorithm}" 

233 ) 

234 LOG().debug(msg) 

235 

236 self._dare_update(_K.STATUS, new_dare_status) 

237 self._dare_update(_K.BALLOT_RESULT, ballot_result) 

238 self._dare_update(_K.BALLOT_ALGORITHM_TYPE, algorithm) 

239 

240 def reset_flagged(self) -> None: 

241 if self.status != DareStatus.FLAGGED: 

242 return 

243 

244 pre_status = self.model.pre_flag_status 

245 actual_status: DareStatus 

246 if pre_status is None: 

247 msg = ( 

248 f"failed to retrieve preFlagStatus for dare_id={self.doc_id}," 

249 ' setting to "processingAfterFlag"' 

250 ) 

251 LOG().warning(msg) 

252 actual_status = DareStatus.RESUBMITTED 

253 else: 

254 actual_status = pre_status 

255 

256 # LOG().debug(f"Unflagging dare {self.objId}, restoring status to {pre_status}") 

257 self._dare_update(_K.FLAG_ID, None) 

258 self._dare_update(_K.PRE_FLAG_STATUS, None) 

259 self._dare_update(_K.STATUS, actual_status) 

260 self._dare_update(_K.ISSUE_PROGRESS, None) 

261 self._dare_update(_K.DISPUTED_PROGRESS, None) 

262 

263 def set_flagged(self, flag_id: str) -> None: 

264 

265 pre_flag_status = self.status 

266 status = DareStatus.FLAGGED 

267 issue_progress = IssueProgress.OPEN 

268 if ( 

269 self.status == DareStatus.FLAGGED 

270 and self.model.pre_flag_status == pre_flag_status 

271 and self.model.flag_id == flag_id 

272 and self.model.issue_progress == issue_progress 

273 ): 

274 if IS_DEBUG: 

275 msg = f"Dare {self.obj_id} is already flagged with the same flagId and status, skipping update" 

276 LOG().debug(msg) 

277 return 

278 

279 self._dare_update(_K.FLAG_ID, flag_id) 

280 self._dare_update(_K.PRE_FLAG_STATUS, pre_flag_status) 

281 self._dare_update(_K.STATUS, status) 

282 self._dare_update(_K.ISSUE_PROGRESS, issue_progress) 

283 self._dare_update(_K.DISPUTED_PROGRESS, None) 

284 

285 @property 

286 def ballot_algorithm_type(self) -> BallotAlgorithmType | None: 

287 return self.model.ballot_algorithm_type 

288 

289 # NOTE: this code should not be required. 

290 # NOTE: trigger data has updated data, so no need to manually update stats.. 

291 # def incrementViewStats( 

292 # self, 

293 # views: int = -1, 

294 # flags: int = -1, 

295 # likes: int = -1, 

296 # dislikes: int = -1, 

297 # ) -> None: 

298 # view_stats = self.model.view_stats 

299 # if views >= 0: 

300 # view_stats.views += views 

301 # if flags >= 0: 

302 # view_stats.flags += flags 

303 # if likes >= 0: 

304 # view_stats.likes += likes 

305 # if dislikes >= 0: 

306 # view_stats.dislikes += dislikes 

307 # self._dare_update(DareKeys.VIEW_STATS, view_stats) 

308 

309 def set_voting_email_sent(self) -> None: 

310 self._dare_update(_K.VOTE_STARTED_EMAIL_SENT, True) 

311 

312 def reindex(self) -> None: 

313 self._dare_update(_K.SEARCH_INDEXED, False) 

314 

315 def set_video(self, video: VideoModel) -> None: 

316 self._dare_update(_K.VIDEO, video) 

317 

318 def set_completed_event_video(self, video: VideoModel) -> None: 

319 completed_event = self.model.completed_event 

320 if completed_event is None: 

321 raise ValueError(f"Dare {self.obj_id} has no completed event to set video on") 

322 

323 completed_event.video = video 

324 self._dare_update(_K.COMPLETED_EVENT, completed_event) 

325 self.completed_event = completed_event 

326 

327 def _dare_update(self, field_name: Enum, value: Any) -> None: 

328 # a wrapper so we can check we have a valid dare model key. 

329 self.update_field(field_name.value, value) 

330 

331 # <AUTO_GENERATED_CONTENT> - do not edit 

332 

333 @property 

334 def from_uid(self) -> str: 

335 return self._model.from_uid 

336 

337 @from_uid.setter 

338 def from_uid(self, value: str) -> None: 

339 self.update_field(_K.FROM_UID, value) 

340 

341 @property 

342 def obj_id(self) -> str: 

343 return self._model.obj_id 

344 

345 @obj_id.setter 

346 def obj_id(self, value: str) -> None: 

347 self.update_field(_K.OBJ_ID, value) 

348 

349 @property 

350 def is_group_dare(self) -> bool: 

351 return self._model.is_group_dare 

352 

353 @is_group_dare.setter 

354 def is_group_dare(self, value: bool) -> None: 

355 self.update_field(_K.IS_GROUP_DARE, value) 

356 

357 @property 

358 def title(self) -> str: 

359 return self._model.title 

360 

361 @title.setter 

362 def title(self, value: str) -> None: 

363 self.update_field(_K.TITLE, value) 

364 

365 @property 

366 def message(self) -> str: 

367 return self._model.message 

368 

369 @message.setter 

370 def message(self, value: str) -> None: 

371 self.update_field(_K.MESSAGE, value) 

372 

373 @property 

374 def video(self) -> VideoModel: 

375 return self._model.video 

376 

377 @video.setter 

378 def video(self, value: VideoModel) -> None: 

379 self.update_field(_K.VIDEO, value) 

380 

381 @property 

382 def accepted_event(self) -> DareEventModel | None: 

383 return self._model.accepted_event 

384 

385 @accepted_event.setter 

386 def accepted_event(self, value: DareEventModel | None) -> None: 

387 self.update_field(_K.ACCEPTED_EVENT, value) 

388 

389 @property 

390 def completed_event(self) -> DareEventModel | None: 

391 return self._model.completed_event 

392 

393 @completed_event.setter 

394 def completed_event(self, value: DareEventModel | None) -> None: 

395 self.update_field(_K.COMPLETED_EVENT, value) 

396 

397 @property 

398 def issue_progress(self) -> IssueProgress | None: 

399 return self._model.issue_progress 

400 

401 @issue_progress.setter 

402 def issue_progress(self, value: IssueProgress | None) -> None: 

403 self.update_field(_K.ISSUE_PROGRESS, value) 

404 

405 @property 

406 def visibility(self) -> AppVisibility: 

407 return self._model.visibility 

408 

409 @visibility.setter 

410 def visibility(self, value: AppVisibility) -> None: 

411 self.update_field(_K.VISIBILITY, value) 

412 

413 @property 

414 def view_stats(self) -> ViewStatsModel: 

415 return self._model.view_stats 

416 

417 @view_stats.setter 

418 def view_stats(self, value: ViewStatsModel) -> None: 

419 self.update_field(_K.VIEW_STATS, value) 

420 

421 @property 

422 def pledge_stats(self) -> PledgeStatsModel: 

423 return self._model.pledge_stats 

424 

425 @pledge_stats.setter 

426 def pledge_stats(self, value: PledgeStatsModel) -> None: 

427 self.update_field(_K.PLEDGE_STATS, value) 

428 

429 # base internal fields 

430 @property 

431 def version(self) -> int: 

432 return self._model.version 

433 

434 @version.setter 

435 def version(self, value: int) -> None: 

436 self.update_field(_K.VERSION, value) 

437 

438 @property 

439 def processed(self) -> bool: 

440 return self._model.processed 

441 

442 @processed.setter 

443 def processed(self, value: bool) -> None: 

444 self.update_field(_K.PROCESSED, value) 

445 

446 @property 

447 def error_count(self) -> int: 

448 return self._model.error_count 

449 

450 @error_count.setter 

451 def error_count(self, value: int) -> None: 

452 self.update_field(_K.ERROR_COUNT, value) 

453 

454 # dare specific internal fields 

455 @property 

456 def thumbnail_created(self) -> bool: 

457 return self._model.thumbnail_created 

458 

459 @thumbnail_created.setter 

460 def thumbnail_created(self, value: bool) -> None: 

461 self.update_field(_K.THUMBNAIL_CREATED, value) 

462 

463 @property 

464 def hash_created(self) -> bool: 

465 return self._model.hash_created 

466 

467 @hash_created.setter 

468 def hash_created(self, value: bool) -> None: 

469 self.update_field(_K.HASH_CREATED, value) 

470 

471 @property 

472 def optimized_video(self) -> bool: 

473 return self._model.optimized_video 

474 

475 @optimized_video.setter 

476 def optimized_video(self, value: bool) -> None: 

477 self.update_field(_K.OPTIMIZED_VIDEO, value) 

478 

479 @property 

480 def search_indexed(self) -> bool: 

481 return self._model.search_indexed 

482 

483 @search_indexed.setter 

484 def search_indexed(self, value: bool) -> None: 

485 self.update_field(_K.SEARCH_INDEXED, value) 

486 

487 @property 

488 def vote_started_email_sent(self) -> bool: 

489 return self._model.vote_started_email_sent 

490 

491 @vote_started_email_sent.setter 

492 def vote_started_email_sent(self, value: bool) -> None: 

493 self.update_field(_K.VOTE_STARTED_EMAIL_SENT, value) 

494 

495 # </AUTO_GENERATED_CONTENT> - do not edit