Coverage for functions \ flipdare \ service \ user_stats_service.py: 0%

65 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 

14from typing import TYPE_CHECKING 

15 

16from flipdare.service._service_provider import ServiceProvider 

17from flipdare.app_log import LOG 

18from flipdare.constants import IS_DEBUG 

19from flipdare.result.app_result import AppResult 

20from flipdare.generated import AppJobType 

21from flipdare.generated.shared.app_error_code import AppErrorCode 

22from flipdare.generated.shared.firestore_collections import FirestoreCollections 

23from flipdare.wrapper.dare_wrapper import DareWrapper 

24from flipdare.wrapper.payment.pledge_wrapper import PledgeWrapper 

25 

26if TYPE_CHECKING: 

27 from flipdare.manager.db_manager import DbManager 

28 from flipdare.manager.backend_manager import BackendManager 

29 

30__all__ = ["UserStatsService"] 

31 

32 

33class UserStatsService(ServiceProvider): 

34 def __init__( 

35 self, 

36 db_manager: DbManager | None = None, 

37 backend_manager: BackendManager | None = None, 

38 ) -> None: 

39 super().__init__( 

40 backend_manager=backend_manager, 

41 db_manager=db_manager, 

42 ) 

43 

44 # TODO: UserStatsService - We separate stats (views, likes, dislikes) etc using a custom callback 

45 """ 

46 Firestore has a limitation of 1 write per document per second. 

47 So in the flutter app: 

48 1. direct updates e.g. dare updates, user profile etc 

49 - these updates are done directly to the user document in firestore 

50 - since the likelihood of multiple updates. 

51 - for things like chats, this is also ok since we can just append to a subcollection and not update the same document 

52 2. stats updates (views, likes, dislikes etc) 

53 - the user makes a direct call to a callback endpoint. 

54 - we add a job if there is enough updates to trigger 

55 - since we are adding a document, we can have multiple updates per second without hitting the 1 write per document per second limit 

56 - the job will then process the updates and update the user document in firestore 

57 

58 this should be a good balance between consistency and performance. 

59 if there are performance issue STATS_SENSITIVITY may need to be tweaked. 

60 

61 FIXME: prioritizing updates so we can process the low priority updates offpeak. 

62 

63 """ 

64 

65 # ======================================================================== 

66 # Helpers 

67 # ======================================================================== 

68 

69 def _update_pledge_stats(self, pledge: PledgeWrapper) -> AppResult[DareWrapper]: 

70 """Update pledge statistics after a pledge is created or updated.""" 

71 pledge_id = pledge.doc_id 

72 main_result = AppResult[DareWrapper](doc_id=pledge_id) 

73 

74 log_creator = self.app_logger 

75 col = FirestoreCollections.PLEDGE 

76 job_type = AppJobType.TR_PLEDGE 

77 

78 dare_db = self.dare_db 

79 dare_id = pledge.dare_id 

80 dare_model = dare_db.get(dare_id) 

81 if dare_model is None: 

82 msg = ( 

83 f"Dare model not found for Dare ID {dare_id}/ " 

84 f"Pledge ID {pledge_id} during pledge stats update." 

85 ) 

86 LOG().error(msg) 

87 log_creator.system_error( 

88 error_code=AppErrorCode.NOT_FOUND, 

89 collection=col, 

90 message=msg, 

91 doc_id=dare_id, 

92 job_type=job_type, 

93 ) 

94 main_result.add_error(AppErrorCode.NOT_FOUND, msg) 

95 return main_result 

96 

97 pledge_amount = pledge.amount 

98 pledge_currency = pledge.currency_code 

99 pledge_stats = dare_model.pledge_stats 

100 pledge_stats.pending.count += 1 

101 usd_cents = self.exchange_rate_monitor.convert_cents_to_usd_cents( 

102 pledge_amount, pledge_currency 

103 ) 

104 if usd_cents is None: 

105 msg = f"Failed to convert pledge amount to USD cents for pledge ID {pledge_id}." 

106 LOG().warning(msg) 

107 log_creator.job_error( 

108 error_code=AppErrorCode.CURRENCY_CONVERSION, 

109 collection=col, 

110 message=msg, 

111 doc_id=pledge_id, 

112 job_type=job_type, 

113 ) 

114 usd_cents = pledge_amount # Fallback to original amount in case of conversion failure 

115 

116 pledge_stats.pending.cents += pledge_amount 

117 pledge_stats.pending.cents_usd += usd_cents 

118 

119 dare_model.pledge_stats = pledge_stats 

120 updates = dare_model.get_updates() 

121 if not updates: 

122 msg = f"No updates found for Dare with ID {dare_id} during pledge stats update." 

123 LOG().warning(msg) 

124 log_creator.job_error( 

125 error_code=AppErrorCode.UNEXPECTED_CODE_PATH, 

126 collection=col, 

127 message=msg, 

128 doc_id=dare_id, 

129 job_type=job_type, 

130 ) 

131 main_result.generated = dare_model 

132 return main_result 

133 

134 if IS_DEBUG: 

135 LOG().debug(f"Updating Dare {dare_id} stats: {updates}") 

136 

137 updated_dare = dare_db.update(dare_id, updates) 

138 if updated_dare is not None: 

139 if IS_DEBUG: 

140 msg = f"Successfully updated Dare with ID {dare_id} during pledge stats update." 

141 LOG().debug(msg) 

142 

143 main_result.generated = dare_model 

144 return main_result 

145 

146 msg = f"Failed to update Dare with ID {dare_id} during pledge stats update." 

147 LOG().error(msg) 

148 log_creator.system_error( 

149 error_code=AppErrorCode.UPDATE_FAILED, 

150 collection=col, 

151 message=msg, 

152 doc_id=dare_id, 

153 job_type=job_type, 

154 ) 

155 main_result.add_error(AppErrorCode.UPDATE_FAILED, msg) 

156 return main_result