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
« 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#
13from __future__ import annotations
15from enum import Enum
16from typing import Any
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
36__all__ = ["DareWrapper"]
38_K = DareKeys
41class DareWrapper(PersistedWrapper[DareModel]):
42 MODEL_CLASS = DareModel
44 @property
45 def status(self) -> DareStatus:
46 return self.model.status
48 @property
49 def pre_flag_status(self) -> DareStatus | None:
50 return self.model.pre_flag_status
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 )
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
68 return is_complete
70 @property
71 def flag_id(self) -> str | None:
72 return self.model.flag_id
74 @property
75 def can_share(self) -> bool:
76 return self.visibility == AppVisibility.PUBLIC and not self.flagged
78 def searchable_values(self) -> list[str]:
79 return self.model.searchable_values
81 @property
82 def short_description(self) -> str:
83 return self.model.short_description
85 @property
86 def short_description_with_from(self) -> str:
87 return self.model.short_description_with_from
89 @property
90 def human_readable_id(self) -> str:
91 return self.model.human_readable_id
93 @property
94 def disputed_progress(self) -> DisputedProgress | None:
95 return self.model.disputed_progress
97 @property
98 def moderation_decision(self) -> ModerationDecision | None:
99 return self.model.moderation_decision
101 @moderation_decision.setter
102 def moderation_decision(self, value: ModerationDecision) -> None:
103 self._dare_update(DareKeys.MODERATION_DECISION, value)
105 # cant set flagged yet, instead set to review_required
106 # if moderation_decision is not approved
108 obj_id = self.model.obj_id
109 old_status: DareStatus = self.model.status
110 status = old_status
111 set_pre_flag: bool = False
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}")
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
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)
148 @property
149 def flagged(self) -> bool:
150 return self.status == DareStatus.FLAGGED
152 @property
153 def has_voting_started(self) -> bool:
154 return self.model.voting_timer is not None
156 @property
157 def ballot_result(self) -> BallotResult | None:
158 return self.model.ballot_result
160 @property
161 def voting_timer(self) -> StopwatchModel | None:
162 return self.model.voting_timer
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
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)
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 .
180 return not timer.is_expired
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)
192 voting_timer = StopwatchModel.from_now(VOTING_DURATION)
193 self._dare_update(_K.VOTING_TIMER, voting_timer)
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
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
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
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)
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)
240 def reset_flagged(self) -> None:
241 if self.status != DareStatus.FLAGGED:
242 return
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
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)
263 def set_flagged(self, flag_id: str) -> None:
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
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)
285 @property
286 def ballot_algorithm_type(self) -> BallotAlgorithmType | None:
287 return self.model.ballot_algorithm_type
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)
309 def set_voting_email_sent(self) -> None:
310 self._dare_update(_K.VOTE_STARTED_EMAIL_SENT, True)
312 def reindex(self) -> None:
313 self._dare_update(_K.SEARCH_INDEXED, False)
315 def set_video(self, video: VideoModel) -> None:
316 self._dare_update(_K.VIDEO, video)
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")
323 completed_event.video = video
324 self._dare_update(_K.COMPLETED_EVENT, completed_event)
325 self.completed_event = completed_event
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)
331 # <AUTO_GENERATED_CONTENT> - do not edit
333 @property
334 def from_uid(self) -> str:
335 return self._model.from_uid
337 @from_uid.setter
338 def from_uid(self, value: str) -> None:
339 self.update_field(_K.FROM_UID, value)
341 @property
342 def obj_id(self) -> str:
343 return self._model.obj_id
345 @obj_id.setter
346 def obj_id(self, value: str) -> None:
347 self.update_field(_K.OBJ_ID, value)
349 @property
350 def is_group_dare(self) -> bool:
351 return self._model.is_group_dare
353 @is_group_dare.setter
354 def is_group_dare(self, value: bool) -> None:
355 self.update_field(_K.IS_GROUP_DARE, value)
357 @property
358 def title(self) -> str:
359 return self._model.title
361 @title.setter
362 def title(self, value: str) -> None:
363 self.update_field(_K.TITLE, value)
365 @property
366 def message(self) -> str:
367 return self._model.message
369 @message.setter
370 def message(self, value: str) -> None:
371 self.update_field(_K.MESSAGE, value)
373 @property
374 def video(self) -> VideoModel:
375 return self._model.video
377 @video.setter
378 def video(self, value: VideoModel) -> None:
379 self.update_field(_K.VIDEO, value)
381 @property
382 def accepted_event(self) -> DareEventModel | None:
383 return self._model.accepted_event
385 @accepted_event.setter
386 def accepted_event(self, value: DareEventModel | None) -> None:
387 self.update_field(_K.ACCEPTED_EVENT, value)
389 @property
390 def completed_event(self) -> DareEventModel | None:
391 return self._model.completed_event
393 @completed_event.setter
394 def completed_event(self, value: DareEventModel | None) -> None:
395 self.update_field(_K.COMPLETED_EVENT, value)
397 @property
398 def issue_progress(self) -> IssueProgress | None:
399 return self._model.issue_progress
401 @issue_progress.setter
402 def issue_progress(self, value: IssueProgress | None) -> None:
403 self.update_field(_K.ISSUE_PROGRESS, value)
405 @property
406 def visibility(self) -> AppVisibility:
407 return self._model.visibility
409 @visibility.setter
410 def visibility(self, value: AppVisibility) -> None:
411 self.update_field(_K.VISIBILITY, value)
413 @property
414 def view_stats(self) -> ViewStatsModel:
415 return self._model.view_stats
417 @view_stats.setter
418 def view_stats(self, value: ViewStatsModel) -> None:
419 self.update_field(_K.VIEW_STATS, value)
421 @property
422 def pledge_stats(self) -> PledgeStatsModel:
423 return self._model.pledge_stats
425 @pledge_stats.setter
426 def pledge_stats(self, value: PledgeStatsModel) -> None:
427 self.update_field(_K.PLEDGE_STATS, value)
429 # base internal fields
430 @property
431 def version(self) -> int:
432 return self._model.version
434 @version.setter
435 def version(self, value: int) -> None:
436 self.update_field(_K.VERSION, value)
438 @property
439 def processed(self) -> bool:
440 return self._model.processed
442 @processed.setter
443 def processed(self, value: bool) -> None:
444 self.update_field(_K.PROCESSED, value)
446 @property
447 def error_count(self) -> int:
448 return self._model.error_count
450 @error_count.setter
451 def error_count(self, value: int) -> None:
452 self.update_field(_K.ERROR_COUNT, value)
454 # dare specific internal fields
455 @property
456 def thumbnail_created(self) -> bool:
457 return self._model.thumbnail_created
459 @thumbnail_created.setter
460 def thumbnail_created(self, value: bool) -> None:
461 self.update_field(_K.THUMBNAIL_CREATED, value)
463 @property
464 def hash_created(self) -> bool:
465 return self._model.hash_created
467 @hash_created.setter
468 def hash_created(self, value: bool) -> None:
469 self.update_field(_K.HASH_CREATED, value)
471 @property
472 def optimized_video(self) -> bool:
473 return self._model.optimized_video
475 @optimized_video.setter
476 def optimized_video(self, value: bool) -> None:
477 self.update_field(_K.OPTIMIZED_VIDEO, value)
479 @property
480 def search_indexed(self) -> bool:
481 return self._model.search_indexed
483 @search_indexed.setter
484 def search_indexed(self, value: bool) -> None:
485 self.update_field(_K.SEARCH_INDEXED, value)
487 @property
488 def vote_started_email_sent(self) -> bool:
489 return self._model.vote_started_email_sent
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)
495 # </AUTO_GENERATED_CONTENT> - do not edit