geek-cafe-saas-sdk 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of geek-cafe-saas-sdk might be problematic. Click here for more details.

Files changed (194) hide show
  1. geek_cafe_saas_sdk/__init__.py +9 -0
  2. geek_cafe_saas_sdk/core/__init__.py +11 -0
  3. geek_cafe_saas_sdk/core/audit_mixin.py +33 -0
  4. geek_cafe_saas_sdk/core/error_codes.py +132 -0
  5. geek_cafe_saas_sdk/core/service_errors.py +19 -0
  6. geek_cafe_saas_sdk/core/service_result.py +121 -0
  7. geek_cafe_saas_sdk/decorators/__init__.py +64 -0
  8. geek_cafe_saas_sdk/decorators/auth.py +373 -0
  9. geek_cafe_saas_sdk/decorators/core.py +358 -0
  10. geek_cafe_saas_sdk/domains/__init__.py +0 -0
  11. geek_cafe_saas_sdk/domains/analytics/__init__.py +0 -0
  12. geek_cafe_saas_sdk/domains/analytics/handlers/__init__.py +0 -0
  13. geek_cafe_saas_sdk/domains/analytics/models/__init__.py +9 -0
  14. geek_cafe_saas_sdk/domains/analytics/models/website_analytics.py +219 -0
  15. geek_cafe_saas_sdk/domains/analytics/models/website_analytics_summary.py +220 -0
  16. geek_cafe_saas_sdk/domains/analytics/services/__init__.py +11 -0
  17. geek_cafe_saas_sdk/domains/analytics/services/website_analytics_service.py +232 -0
  18. geek_cafe_saas_sdk/domains/analytics/services/website_analytics_summary_service.py +212 -0
  19. geek_cafe_saas_sdk/domains/analytics/services/website_analytics_tally_service.py +610 -0
  20. geek_cafe_saas_sdk/domains/auth/__init__.py +0 -0
  21. geek_cafe_saas_sdk/domains/auth/handlers/__init__.py +0 -0
  22. geek_cafe_saas_sdk/domains/auth/handlers/users/create/app.py +41 -0
  23. geek_cafe_saas_sdk/domains/auth/handlers/users/delete/app.py +41 -0
  24. geek_cafe_saas_sdk/domains/auth/handlers/users/get/app.py +39 -0
  25. geek_cafe_saas_sdk/domains/auth/handlers/users/list/app.py +36 -0
  26. geek_cafe_saas_sdk/domains/auth/handlers/users/update/app.py +44 -0
  27. geek_cafe_saas_sdk/domains/auth/models/__init__.py +13 -0
  28. geek_cafe_saas_sdk/domains/auth/models/permission.py +134 -0
  29. geek_cafe_saas_sdk/domains/auth/models/resource_permission.py +245 -0
  30. geek_cafe_saas_sdk/domains/auth/models/role.py +213 -0
  31. geek_cafe_saas_sdk/domains/auth/models/user.py +285 -0
  32. geek_cafe_saas_sdk/domains/auth/services/__init__.py +16 -0
  33. geek_cafe_saas_sdk/domains/auth/services/authorization_service.py +376 -0
  34. geek_cafe_saas_sdk/domains/auth/services/permission_registry.py +464 -0
  35. geek_cafe_saas_sdk/domains/auth/services/resource_permission_service.py +408 -0
  36. geek_cafe_saas_sdk/domains/auth/services/user_service.py +274 -0
  37. geek_cafe_saas_sdk/domains/communities/__init__.py +0 -0
  38. geek_cafe_saas_sdk/domains/communities/handlers/__init__.py +0 -0
  39. geek_cafe_saas_sdk/domains/communities/handlers/communities/create/app.py +41 -0
  40. geek_cafe_saas_sdk/domains/communities/handlers/communities/delete/app.py +41 -0
  41. geek_cafe_saas_sdk/domains/communities/handlers/communities/get/app.py +39 -0
  42. geek_cafe_saas_sdk/domains/communities/handlers/communities/list/app.py +36 -0
  43. geek_cafe_saas_sdk/domains/communities/handlers/communities/update/app.py +44 -0
  44. geek_cafe_saas_sdk/domains/communities/models/__init__.py +6 -0
  45. geek_cafe_saas_sdk/domains/communities/models/community.py +326 -0
  46. geek_cafe_saas_sdk/domains/communities/models/community_member.py +227 -0
  47. geek_cafe_saas_sdk/domains/communities/services/__init__.py +6 -0
  48. geek_cafe_saas_sdk/domains/communities/services/community_member_service.py +412 -0
  49. geek_cafe_saas_sdk/domains/communities/services/community_service.py +479 -0
  50. geek_cafe_saas_sdk/domains/events/__init__.py +0 -0
  51. geek_cafe_saas_sdk/domains/events/handlers/__init__.py +0 -0
  52. geek_cafe_saas_sdk/domains/events/handlers/attendees/app.py +67 -0
  53. geek_cafe_saas_sdk/domains/events/handlers/cancel/app.py +66 -0
  54. geek_cafe_saas_sdk/domains/events/handlers/check_in/app.py +60 -0
  55. geek_cafe_saas_sdk/domains/events/handlers/create/app.py +93 -0
  56. geek_cafe_saas_sdk/domains/events/handlers/delete/app.py +42 -0
  57. geek_cafe_saas_sdk/domains/events/handlers/get/app.py +39 -0
  58. geek_cafe_saas_sdk/domains/events/handlers/invite/app.py +98 -0
  59. geek_cafe_saas_sdk/domains/events/handlers/list/app.py +125 -0
  60. geek_cafe_saas_sdk/domains/events/handlers/publish/app.py +49 -0
  61. geek_cafe_saas_sdk/domains/events/handlers/rsvp/app.py +83 -0
  62. geek_cafe_saas_sdk/domains/events/handlers/update/app.py +44 -0
  63. geek_cafe_saas_sdk/domains/events/models/__init__.py +3 -0
  64. geek_cafe_saas_sdk/domains/events/models/event.py +681 -0
  65. geek_cafe_saas_sdk/domains/events/models/event_attendee.py +324 -0
  66. geek_cafe_saas_sdk/domains/events/services/__init__.py +9 -0
  67. geek_cafe_saas_sdk/domains/events/services/event_attendee_service.py +571 -0
  68. geek_cafe_saas_sdk/domains/events/services/event_service.py +684 -0
  69. geek_cafe_saas_sdk/domains/files/__init__.py +0 -0
  70. geek_cafe_saas_sdk/domains/files/models/__init__.py +0 -0
  71. geek_cafe_saas_sdk/domains/files/models/directory.py +258 -0
  72. geek_cafe_saas_sdk/domains/files/models/file.py +312 -0
  73. geek_cafe_saas_sdk/domains/files/models/file_share.py +268 -0
  74. geek_cafe_saas_sdk/domains/files/models/file_version.py +216 -0
  75. geek_cafe_saas_sdk/domains/files/services/__init__.py +0 -0
  76. geek_cafe_saas_sdk/domains/files/services/directory_service.py +701 -0
  77. geek_cafe_saas_sdk/domains/files/services/file_share_service.py +663 -0
  78. geek_cafe_saas_sdk/domains/files/services/file_system_service.py +575 -0
  79. geek_cafe_saas_sdk/domains/files/services/file_version_service.py +739 -0
  80. geek_cafe_saas_sdk/domains/files/services/s3_file_service.py +501 -0
  81. geek_cafe_saas_sdk/domains/messaging/__init__.py +0 -0
  82. geek_cafe_saas_sdk/domains/messaging/handlers/__init__.py +0 -0
  83. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/create/app.py +86 -0
  84. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/delete/app.py +65 -0
  85. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/get/app.py +64 -0
  86. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/list/app.py +97 -0
  87. geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/update/app.py +149 -0
  88. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/create/app.py +67 -0
  89. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/delete/app.py +65 -0
  90. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/get/app.py +64 -0
  91. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/list/app.py +102 -0
  92. geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/update/app.py +127 -0
  93. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/create/app.py +94 -0
  94. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/delete/app.py +66 -0
  95. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/get/app.py +67 -0
  96. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/list/app.py +95 -0
  97. geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/update/app.py +156 -0
  98. geek_cafe_saas_sdk/domains/messaging/models/__init__.py +13 -0
  99. geek_cafe_saas_sdk/domains/messaging/models/chat_channel.py +337 -0
  100. geek_cafe_saas_sdk/domains/messaging/models/chat_channel_member.py +180 -0
  101. geek_cafe_saas_sdk/domains/messaging/models/chat_message.py +426 -0
  102. geek_cafe_saas_sdk/domains/messaging/models/contact_thread.py +392 -0
  103. geek_cafe_saas_sdk/domains/messaging/services/__init__.py +11 -0
  104. geek_cafe_saas_sdk/domains/messaging/services/chat_channel_service.py +700 -0
  105. geek_cafe_saas_sdk/domains/messaging/services/chat_message_service.py +491 -0
  106. geek_cafe_saas_sdk/domains/messaging/services/contact_thread_service.py +497 -0
  107. geek_cafe_saas_sdk/domains/tenancy/__init__.py +0 -0
  108. geek_cafe_saas_sdk/domains/tenancy/handlers/__init__.py +0 -0
  109. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/activate/app.py +52 -0
  110. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/active/app.py +37 -0
  111. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/cancel/app.py +55 -0
  112. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/get/app.py +39 -0
  113. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/list/app.py +44 -0
  114. geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/record_payment/app.py +56 -0
  115. geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/get/app.py +39 -0
  116. geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/me/app.py +37 -0
  117. geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/signup/app.py +61 -0
  118. geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/update/app.py +44 -0
  119. geek_cafe_saas_sdk/domains/tenancy/models/__init__.py +6 -0
  120. geek_cafe_saas_sdk/domains/tenancy/models/subscription.py +440 -0
  121. geek_cafe_saas_sdk/domains/tenancy/models/tenant.py +258 -0
  122. geek_cafe_saas_sdk/domains/tenancy/services/__init__.py +6 -0
  123. geek_cafe_saas_sdk/domains/tenancy/services/subscription_service.py +557 -0
  124. geek_cafe_saas_sdk/domains/tenancy/services/tenant_service.py +575 -0
  125. geek_cafe_saas_sdk/domains/voting/__init__.py +0 -0
  126. geek_cafe_saas_sdk/domains/voting/handlers/__init__.py +0 -0
  127. geek_cafe_saas_sdk/domains/voting/handlers/votes/create/app.py +128 -0
  128. geek_cafe_saas_sdk/domains/voting/handlers/votes/delete/app.py +41 -0
  129. geek_cafe_saas_sdk/domains/voting/handlers/votes/get/app.py +39 -0
  130. geek_cafe_saas_sdk/domains/voting/handlers/votes/list/app.py +38 -0
  131. geek_cafe_saas_sdk/domains/voting/handlers/votes/summerize/README.md +3 -0
  132. geek_cafe_saas_sdk/domains/voting/handlers/votes/update/app.py +44 -0
  133. geek_cafe_saas_sdk/domains/voting/models/__init__.py +9 -0
  134. geek_cafe_saas_sdk/domains/voting/models/vote.py +231 -0
  135. geek_cafe_saas_sdk/domains/voting/models/vote_summary.py +193 -0
  136. geek_cafe_saas_sdk/domains/voting/services/__init__.py +11 -0
  137. geek_cafe_saas_sdk/domains/voting/services/vote_service.py +264 -0
  138. geek_cafe_saas_sdk/domains/voting/services/vote_summary_service.py +198 -0
  139. geek_cafe_saas_sdk/domains/voting/services/vote_tally_service.py +533 -0
  140. geek_cafe_saas_sdk/lambda_handlers/README.md +404 -0
  141. geek_cafe_saas_sdk/lambda_handlers/__init__.py +67 -0
  142. geek_cafe_saas_sdk/lambda_handlers/_base/__init__.py +25 -0
  143. geek_cafe_saas_sdk/lambda_handlers/_base/api_key_handler.py +129 -0
  144. geek_cafe_saas_sdk/lambda_handlers/_base/authorized_secure_handler.py +218 -0
  145. geek_cafe_saas_sdk/lambda_handlers/_base/base_handler.py +185 -0
  146. geek_cafe_saas_sdk/lambda_handlers/_base/handler_factory.py +256 -0
  147. geek_cafe_saas_sdk/lambda_handlers/_base/public_handler.py +53 -0
  148. geek_cafe_saas_sdk/lambda_handlers/_base/secure_handler.py +89 -0
  149. geek_cafe_saas_sdk/lambda_handlers/_base/service_pool.py +94 -0
  150. geek_cafe_saas_sdk/lambda_handlers/directories/create/app.py +79 -0
  151. geek_cafe_saas_sdk/lambda_handlers/directories/delete/app.py +76 -0
  152. geek_cafe_saas_sdk/lambda_handlers/directories/get/app.py +74 -0
  153. geek_cafe_saas_sdk/lambda_handlers/directories/list/app.py +75 -0
  154. geek_cafe_saas_sdk/lambda_handlers/directories/move/app.py +79 -0
  155. geek_cafe_saas_sdk/lambda_handlers/files/delete/app.py +121 -0
  156. geek_cafe_saas_sdk/lambda_handlers/files/download/app.py +187 -0
  157. geek_cafe_saas_sdk/lambda_handlers/files/get/app.py +127 -0
  158. geek_cafe_saas_sdk/lambda_handlers/files/list/app.py +108 -0
  159. geek_cafe_saas_sdk/lambda_handlers/files/share/app.py +83 -0
  160. geek_cafe_saas_sdk/lambda_handlers/files/shares/list/app.py +84 -0
  161. geek_cafe_saas_sdk/lambda_handlers/files/shares/revoke/app.py +76 -0
  162. geek_cafe_saas_sdk/lambda_handlers/files/update/app.py +143 -0
  163. geek_cafe_saas_sdk/lambda_handlers/files/upload/app.py +151 -0
  164. geek_cafe_saas_sdk/middleware/__init__.py +36 -0
  165. geek_cafe_saas_sdk/middleware/auth.py +85 -0
  166. geek_cafe_saas_sdk/middleware/authorization.py +523 -0
  167. geek_cafe_saas_sdk/middleware/cors.py +63 -0
  168. geek_cafe_saas_sdk/middleware/error_handling.py +114 -0
  169. geek_cafe_saas_sdk/middleware/validation.py +80 -0
  170. geek_cafe_saas_sdk/models/__init__.py +20 -0
  171. geek_cafe_saas_sdk/models/base_model.py +233 -0
  172. geek_cafe_saas_sdk/services/__init__.py +18 -0
  173. geek_cafe_saas_sdk/services/database_service.py +441 -0
  174. geek_cafe_saas_sdk/utilities/__init__.py +88 -0
  175. geek_cafe_saas_sdk/utilities/cognito_utility.py +568 -0
  176. geek_cafe_saas_sdk/utilities/custom_exceptions.py +183 -0
  177. geek_cafe_saas_sdk/utilities/datetime_utility.py +410 -0
  178. geek_cafe_saas_sdk/utilities/dictionary_utility.py +78 -0
  179. geek_cafe_saas_sdk/utilities/dynamodb_utils.py +151 -0
  180. geek_cafe_saas_sdk/utilities/environment_loader.py +149 -0
  181. geek_cafe_saas_sdk/utilities/environment_variables.py +228 -0
  182. geek_cafe_saas_sdk/utilities/http_body_parameters.py +44 -0
  183. geek_cafe_saas_sdk/utilities/http_path_parameters.py +60 -0
  184. geek_cafe_saas_sdk/utilities/http_status_code.py +63 -0
  185. geek_cafe_saas_sdk/utilities/jwt_utility.py +234 -0
  186. geek_cafe_saas_sdk/utilities/lambda_event_utility.py +776 -0
  187. geek_cafe_saas_sdk/utilities/logging_utility.py +64 -0
  188. geek_cafe_saas_sdk/utilities/message_query_helper.py +340 -0
  189. geek_cafe_saas_sdk/utilities/response.py +209 -0
  190. geek_cafe_saas_sdk/utilities/string_functions.py +180 -0
  191. geek_cafe_saas_sdk-0.6.0.dist-info/METADATA +397 -0
  192. geek_cafe_saas_sdk-0.6.0.dist-info/RECORD +194 -0
  193. geek_cafe_saas_sdk-0.6.0.dist-info/WHEEL +4 -0
  194. geek_cafe_saas_sdk-0.6.0.dist-info/licenses/LICENSE +47 -0
@@ -0,0 +1,533 @@
1
+ # Vote Tally Service
2
+
3
+ from typing import Dict, Any, Optional, List
4
+ from boto3_assist.dynamodb.dynamodb import DynamoDB
5
+ from geek_cafe_saas_sdk.core.service_result import ServiceResult
6
+ from geek_cafe_saas_sdk.core.error_codes import ErrorCode
7
+ from .vote_service import VoteService
8
+ from .vote_summary_service import VoteSummaryService
9
+ from geek_cafe_saas_sdk.domains.voting.models import Vote, VoteSummary
10
+ from aws_lambda_powertools import Logger
11
+ import os
12
+ import time
13
+
14
+ logger = Logger()
15
+
16
+
17
+ class VoteTallyService:
18
+ """Service for tallying votes and updating vote summaries."""
19
+
20
+ def __init__(self, *, dynamodb: DynamoDB = None, table_name: str = None):
21
+ self.vote_service = VoteService(dynamodb=dynamodb, table_name=table_name)
22
+ self.vote_summary_service = VoteSummaryService(dynamodb=dynamodb, table_name=table_name)
23
+ self.page_size = 100 # Configurable page size for pagination
24
+
25
+ # Pagination monitoring configuration from environment variables
26
+ self.max_pagination_iterations = int(os.getenv('TALLY_MAX_PAGINATION_ITERATIONS', '50'))
27
+ self.max_pagination_time_seconds = int(os.getenv('TALLY_MAX_PAGINATION_TIME_SECONDS', '30'))
28
+ self.halt_on_pagination_limit = os.getenv('TALLY_HALT_ON_PAGINATION_LIMIT', 'false').lower() == 'true'
29
+
30
+ def tally_votes_for_target(self, target_id: str, tenant_id: str, user_id: str) -> ServiceResult[VoteSummary]:
31
+ """
32
+ Tally all votes for a specific target and update/create the vote summary.
33
+
34
+ This method handles all voting patterns: single_choice, multi_select, ranking, rating.
35
+
36
+ Args:
37
+ target_id: The target to tally votes for
38
+ tenant_id: Tenant ID for access control
39
+ user_id: User ID for audit trail
40
+
41
+ Returns:
42
+ ServiceResult containing the updated VoteSummary
43
+ """
44
+ try:
45
+ logger.info(f"Starting enhanced vote tally for target: {target_id}")
46
+
47
+ # Get all votes for this target with pagination support
48
+ all_votes = []
49
+ start_key = None
50
+ pagination_iterations = 0
51
+ pagination_start_time = time.time()
52
+
53
+ while True:
54
+ pagination_iterations += 1
55
+ pagination_elapsed = time.time() - pagination_start_time
56
+
57
+ # Check pagination limits
58
+ if pagination_iterations > self.max_pagination_iterations:
59
+ logger.warning(
60
+ "Pagination iteration limit exceeded",
61
+ extra={
62
+ "metric_name": "TallyPaginationIterationsExceeded",
63
+ "metric_value": pagination_iterations,
64
+ "target_id": target_id,
65
+ "votes_collected": len(all_votes),
66
+ "max_iterations": self.max_pagination_iterations
67
+ }
68
+ )
69
+ if self.halt_on_pagination_limit:
70
+ logger.error(f"Halting pagination after {pagination_iterations} iterations")
71
+ break
72
+
73
+ if pagination_elapsed > self.max_pagination_time_seconds:
74
+ logger.warning(
75
+ "Pagination time limit exceeded",
76
+ extra={
77
+ "metric_name": "TallyPaginationTimeExceeded",
78
+ "metric_value": pagination_elapsed,
79
+ "target_id": target_id,
80
+ "votes_collected": len(all_votes),
81
+ "max_time_seconds": self.max_pagination_time_seconds
82
+ }
83
+ )
84
+ if self.halt_on_pagination_limit:
85
+ logger.error(f"Halting pagination after {pagination_elapsed:.2f} seconds")
86
+ break
87
+
88
+ votes_result = self.vote_service.list_by_target(target_id, start_key=start_key)
89
+
90
+ if not votes_result.success:
91
+ logger.error(f"Failed to retrieve votes for target {target_id}: {votes_result.message}")
92
+ return ServiceResult.error_result(
93
+ message=f"Failed to retrieve votes: {votes_result.message}",
94
+ error_code=votes_result.error_code
95
+ )
96
+
97
+ # Add this page of results
98
+ if votes_result.data:
99
+ all_votes.extend(votes_result.data)
100
+
101
+ # Check if there are more pages via error_details
102
+ if (votes_result.error_details and
103
+ 'last_evaluated_key' in votes_result.error_details):
104
+ start_key = votes_result.error_details['last_evaluated_key']
105
+ logger.debug(f"Fetching next page of votes, total so far: {len(all_votes)}")
106
+ else:
107
+ # No more pages
108
+ break
109
+
110
+ # Log pagination metrics
111
+ logger.info(
112
+ "Pagination completed for vote tally",
113
+ extra={
114
+ "metric_name": "TallyPaginationCompleted",
115
+ "iterations": pagination_iterations,
116
+ "elapsed_seconds": pagination_elapsed,
117
+ "votes_collected": len(all_votes),
118
+ "target_id": target_id
119
+ }
120
+ )
121
+
122
+ votes = all_votes
123
+
124
+ if not votes:
125
+ # No votes - create empty summary
126
+ return self._create_empty_summary(target_id, tenant_id, user_id)
127
+
128
+ # Determine vote type from first vote (all should be same type for a target)
129
+ vote_type = votes[0].vote_type if votes else "single_choice"
130
+
131
+ # Tally based on vote type
132
+ if vote_type == "single_choice":
133
+ summary_data = self._tally_single_choice_votes(votes)
134
+ elif vote_type == "multi_select":
135
+ summary_data = self._tally_multi_select_votes(votes)
136
+ elif vote_type == "ranking":
137
+ summary_data = self._tally_ranking_votes(votes)
138
+ elif vote_type == "rating":
139
+ summary_data = self._tally_rating_votes(votes)
140
+ elif vote_type == "legacy":
141
+ # Legacy binary votes
142
+ summary_data = self._tally_legacy_votes(votes)
143
+ else:
144
+ # Default to legacy for unknown types
145
+ summary_data = self._tally_legacy_votes(votes)
146
+
147
+ logger.info(f"Tallying complete for target {target_id}: {len(votes)} votes processed")
148
+
149
+ # Create or update the vote summary
150
+ summary_result = self._create_or_update_summary(
151
+ target_id, tenant_id, user_id, vote_type, summary_data
152
+ )
153
+
154
+ if summary_result.success:
155
+ logger.info(f"Vote summary updated for target {target_id}: {summary_data}")
156
+
157
+ return summary_result
158
+
159
+ except Exception as e:
160
+ logger.error(f"Error tallying votes for target {target_id}: {str(e)}")
161
+ return ServiceResult.exception_result(
162
+ e,
163
+ error_code=ErrorCode.OPERATION_FAILED,
164
+ context=f"Failed to tally votes for target {target_id}"
165
+ )
166
+
167
+ def _tally_single_choice_votes(self, votes) -> Dict[str, Any]:
168
+ """Tally single choice votes (A/B/C/D tests)."""
169
+ choice_counts = {}
170
+ total_participants = len(votes)
171
+
172
+ # Legacy counters for backward compatibility
173
+ total_up_votes = 0
174
+ total_down_votes = 0
175
+
176
+ for vote in votes:
177
+ # Count legacy fields
178
+ total_up_votes += vote.up_vote
179
+ total_down_votes += vote.down_vote
180
+
181
+ # Count choices from enhanced data
182
+ selected_choices = vote.get_selected_choices()
183
+ for choice in selected_choices:
184
+ choice_counts[choice] = choice_counts.get(choice, 0) + 1
185
+
186
+ return {
187
+ "choice_breakdown": choice_counts,
188
+ "total_participants": total_participants,
189
+ "total_selections": total_participants, # Same as participants for single choice
190
+ "total_up_votes": total_up_votes,
191
+ "total_down_votes": total_down_votes,
192
+ "total_votes": total_up_votes + total_down_votes
193
+ }
194
+
195
+ def _tally_multi_select_votes(self, votes) -> Dict[str, Any]:
196
+ """Tally multi-select votes."""
197
+ choice_counts = {}
198
+ total_participants = len(votes)
199
+ total_selections = 0
200
+
201
+ for vote in votes:
202
+ selected_choices = vote.get_selected_choices()
203
+ total_selections += len(selected_choices)
204
+
205
+ for choice in selected_choices:
206
+ choice_counts[choice] = choice_counts.get(choice, 0) + 1
207
+
208
+ return {
209
+ "choice_breakdown": choice_counts,
210
+ "total_participants": total_participants,
211
+ "total_selections": total_selections,
212
+ "total_up_votes": total_selections, # Legacy: total selections
213
+ "total_down_votes": 0,
214
+ "total_votes": total_selections
215
+ }
216
+
217
+ def _tally_ranking_votes(self, votes) -> Dict[str, Any]:
218
+ """Tally ranking votes with weighted scoring."""
219
+ choice_scores = {}
220
+ choice_counts = {}
221
+ total_participants = len(votes)
222
+
223
+ for vote in votes:
224
+ for choice_id, choice_data in vote.choices.items():
225
+ rank = choice_data.get("rank", 999)
226
+ value = choice_data.get("value", 0)
227
+
228
+ choice_scores[choice_id] = choice_scores.get(choice_id, 0) + value
229
+ choice_counts[choice_id] = choice_counts.get(choice_id, 0) + 1
230
+
231
+ return {
232
+ "choice_breakdown": choice_counts,
233
+ "choice_scores": choice_scores, # Weighted scores
234
+ "total_participants": total_participants,
235
+ "total_selections": sum(choice_counts.values()),
236
+ "total_up_votes": sum(choice_scores.values()), # Legacy: total score
237
+ "total_down_votes": 0,
238
+ "total_votes": sum(choice_scores.values())
239
+ }
240
+
241
+ def _tally_rating_votes(self, votes) -> Dict[str, Any]:
242
+ """Tally rating votes with average ratings."""
243
+ choice_ratings = {}
244
+ choice_counts = {}
245
+ total_participants = len(votes)
246
+
247
+ for vote in votes:
248
+ for choice_id, choice_data in vote.choices.items():
249
+ rating = choice_data.get("rating", 0)
250
+
251
+ if choice_id not in choice_ratings:
252
+ choice_ratings[choice_id] = []
253
+ choice_ratings[choice_id].append(rating)
254
+ choice_counts[choice_id] = choice_counts.get(choice_id, 0) + 1
255
+
256
+ # Calculate average ratings
257
+ choice_averages = {
258
+ choice: sum(ratings) / len(ratings)
259
+ for choice, ratings in choice_ratings.items()
260
+ }
261
+
262
+ return {
263
+ "choice_breakdown": choice_counts,
264
+ "choice_averages": choice_averages, # Average ratings
265
+ "total_participants": total_participants,
266
+ "total_selections": sum(choice_counts.values()),
267
+ "total_up_votes": int(sum(choice_averages.values())), # Legacy: sum of averages
268
+ "total_down_votes": 0,
269
+ "total_votes": int(sum(choice_averages.values()))
270
+ }
271
+
272
+ def _tally_legacy_votes(self, votes) -> Dict[str, Any]:
273
+ """Tally legacy binary votes."""
274
+ total_up_votes = sum(vote.up_vote for vote in votes)
275
+ total_down_votes = sum(vote.down_vote for vote in votes)
276
+
277
+ # Create choice breakdown from binary data
278
+ choice_breakdown = {}
279
+ if total_up_votes > 0:
280
+ choice_breakdown["up"] = total_up_votes
281
+ if total_down_votes > 0:
282
+ choice_breakdown["down"] = total_down_votes
283
+
284
+ return {
285
+ "choice_breakdown": choice_breakdown,
286
+ "total_participants": len(votes),
287
+ "total_selections": total_up_votes + total_down_votes,
288
+ "total_up_votes": total_up_votes,
289
+ "total_down_votes": total_down_votes,
290
+ "total_votes": total_up_votes + total_down_votes
291
+ }
292
+
293
+ def _create_empty_summary(self, target_id: str, tenant_id: str, user_id: str) -> ServiceResult[VoteSummary]:
294
+ """Create an empty summary for targets with no votes."""
295
+ return self.vote_summary_service.create(
296
+ tenant_id=tenant_id,
297
+ user_id=user_id,
298
+ target_id=target_id,
299
+ vote_type="single_choice",
300
+ choice_breakdown={},
301
+ choice_percentages={},
302
+ total_participants=0,
303
+ total_selections=0,
304
+ total_up_votes=0,
305
+ total_down_votes=0,
306
+ total_votes=0,
307
+ content={
308
+ "last_tallied_utc_ts": self._get_current_timestamp(),
309
+ "vote_count": 0
310
+ }
311
+ )
312
+
313
+ def _create_or_update_summary(self, target_id: str, tenant_id: str, user_id: str,
314
+ vote_type: str, summary_data: Dict[str, Any]) -> ServiceResult[VoteSummary]:
315
+ """Create or update vote summary with enhanced data."""
316
+
317
+ # Calculate percentages
318
+ choice_breakdown = summary_data["choice_breakdown"]
319
+ total_participants = summary_data["total_participants"]
320
+
321
+ choice_percentages = {}
322
+ if total_participants > 0:
323
+ choice_percentages = {
324
+ choice: (count / total_participants * 100)
325
+ for choice, count in choice_breakdown.items()
326
+ }
327
+
328
+ return self.vote_summary_service.create(
329
+ tenant_id=tenant_id,
330
+ user_id=user_id,
331
+ target_id=target_id,
332
+ vote_type=vote_type,
333
+ choice_breakdown=choice_breakdown,
334
+ choice_percentages=choice_percentages,
335
+ choice_averages=summary_data.get("choice_averages", {}), # For rating votes
336
+ total_participants=total_participants,
337
+ total_selections=summary_data["total_selections"],
338
+ total_up_votes=summary_data["total_up_votes"],
339
+ total_down_votes=summary_data["total_down_votes"],
340
+ total_votes=summary_data["total_votes"],
341
+ content={
342
+ "last_tallied_utc_ts": self._get_current_timestamp(),
343
+ "vote_count": total_participants
344
+ }
345
+ )
346
+
347
+ def _get_votes_page(self, target_id: str, start_key: Optional[dict] = None) -> ServiceResult[Dict[str, Any]]:
348
+ """
349
+ Get a page of votes for a target using the vote service's list_by_target method.
350
+
351
+ Returns:
352
+ ServiceResult with data containing 'items' and optional 'last_evaluated_key'
353
+ """
354
+ try:
355
+ # For simplicity in testing, we'll get all votes at once
356
+ # In production, you would implement proper pagination here
357
+ result = self.vote_service.list_by_target(target_id)
358
+
359
+ if result.success:
360
+ items = result.data
361
+
362
+ # For testing purposes, return all items at once
363
+ # In production, you would implement proper DynamoDB pagination
364
+ page_items = items
365
+ has_more = False
366
+
367
+ return ServiceResult.success_result({
368
+ 'items': page_items,
369
+ 'last_evaluated_key': {'page': 'next'} if has_more else None
370
+ })
371
+ else:
372
+ return result
373
+
374
+ except Exception as e:
375
+ return ServiceResult.exception_result(
376
+ e,
377
+ error_code=ErrorCode.DATABASE_QUERY_FAILED,
378
+ context=f"Failed to query votes for target {target_id}"
379
+ )
380
+
381
+ def tally_votes_for_multiple_targets(self, target_ids: List[str], tenant_id: str, user_id: str) -> ServiceResult[List[VoteSummary]]:
382
+ """
383
+ Tally votes for multiple targets efficiently.
384
+
385
+ This is useful for batch processing or scheduled jobs.
386
+
387
+ Args:
388
+ target_ids: List of target IDs to process
389
+ tenant_id: Tenant ID for access control
390
+ user_id: User ID for audit trail
391
+
392
+ Returns:
393
+ ServiceResult containing list of updated VoteSummaries
394
+ """
395
+ try:
396
+ logger.info(f"Starting batch tally for {len(target_ids)} targets")
397
+
398
+ summaries = []
399
+ failed_targets = []
400
+
401
+ for target_id in target_ids:
402
+ result = self.tally_votes_for_target(target_id, tenant_id, user_id)
403
+
404
+ if result.success:
405
+ summaries.append(result.data)
406
+ else:
407
+ failed_targets.append({
408
+ 'target_id': target_id,
409
+ 'message': result.message,
410
+ 'error_code': result.error_code
411
+ })
412
+ logger.warning(f"Failed to tally votes for target {target_id}: {result.message}")
413
+
414
+ if failed_targets:
415
+ logger.warning(f"Batch tally completed with {len(failed_targets)} failures out of {len(target_ids)} targets")
416
+ return ServiceResult.error_result(
417
+ message=f"Batch tally completed with failures: {len(failed_targets)}/{len(target_ids)} failed",
418
+ error_code=ErrorCode.PARTIAL_FAILURE,
419
+ error_details={
420
+ 'successful_count': len(summaries),
421
+ 'failed_count': len(failed_targets),
422
+ 'failed_targets': failed_targets,
423
+ 'successful_summaries': summaries
424
+ }
425
+ )
426
+ else:
427
+ logger.info(f"Batch tally completed successfully for all {len(target_ids)} targets")
428
+ return ServiceResult.success_result(summaries)
429
+
430
+ except Exception as e:
431
+ logger.error(f"Error in batch tally operation: {str(e)}")
432
+ return ServiceResult.exception_result(
433
+ e,
434
+ error_code=ErrorCode.BATCH_OPERATION_FAILED,
435
+ context="Failed to process batch tally operation"
436
+ )
437
+
438
+ def get_stale_targets(self, tenant_id: str, hours_threshold: int = 24) -> ServiceResult[List[str]]:
439
+ """
440
+ Get list of targets that haven't been tallied recently.
441
+
442
+ This is useful for identifying targets that need re-tallying.
443
+
444
+ Args:
445
+ tenant_id: Tenant ID to scope the search
446
+ hours_threshold: Hours since last tally to consider stale
447
+
448
+ Returns:
449
+ ServiceResult containing list of target IDs that need tallying
450
+ """
451
+ try:
452
+ # Get all vote summaries for the tenant
453
+ summaries_result = self.vote_summary_service.list_by_tenant(tenant_id)
454
+
455
+ if not summaries_result.success:
456
+ return summaries_result
457
+
458
+ current_time = self._get_current_timestamp()
459
+ threshold_time = current_time - (hours_threshold * 3600) # Convert hours to seconds
460
+
461
+ stale_targets = []
462
+
463
+ for summary in summaries_result.data:
464
+ last_tallied = summary.content.get('last_tallied_utc_ts', 0)
465
+
466
+ if last_tallied < threshold_time:
467
+ stale_targets.append(summary.target_id)
468
+
469
+ logger.info(f"Found {len(stale_targets)} stale targets (older than {hours_threshold} hours)")
470
+ return ServiceResult.success_result(stale_targets)
471
+
472
+ except Exception as e:
473
+ logger.error(f"Error finding stale targets: {str(e)}")
474
+ return ServiceResult.exception_result(
475
+ e,
476
+ error_code=ErrorCode.DATABASE_QUERY_FAILED,
477
+ context="Failed to query for stale targets"
478
+ )
479
+
480
+ def _get_current_timestamp(self) -> float:
481
+ """Get current UTC timestamp."""
482
+ import datetime as dt
483
+ return dt.datetime.now(dt.UTC).timestamp()
484
+
485
+
486
+ class VoteTallyServiceEnhanced(VoteTallyService):
487
+ """
488
+ Enhanced version with true pagination support.
489
+
490
+ This version demonstrates how to implement proper pagination
491
+ when the underlying service supports it.
492
+ """
493
+
494
+ def _get_votes_page_with_pagination(self, target_id: str, start_key: Optional[dict] = None) -> ServiceResult[Dict[str, Any]]:
495
+ """
496
+ Enhanced version that would use true pagination if the vote service supported it.
497
+
498
+ This is how you would implement it with proper DynamoDB pagination:
499
+ """
500
+ try:
501
+ # Create a vote model for querying
502
+ vote_model = Vote()
503
+ vote_model.target_id = target_id
504
+
505
+ # Use the database service's _query_by_index method directly with pagination
506
+ # This would require access to the underlying database service
507
+ # For now, we'll simulate the structure
508
+
509
+ # In a real implementation, you might do:
510
+ # result = self.vote_service._query_by_index(
511
+ # vote_model,
512
+ # "gsi5", # target index
513
+ # start_key=start_key,
514
+ # limit=self.page_size
515
+ # )
516
+
517
+ # For demonstration, we'll use the existing method
518
+ result = self.vote_service.list_by_target(target_id)
519
+
520
+ if result.success:
521
+ return ServiceResult.success_result({
522
+ 'items': result.data,
523
+ 'last_evaluated_key': None # Would come from DynamoDB response
524
+ })
525
+ else:
526
+ return result
527
+
528
+ except Exception as e:
529
+ return ServiceResult.exception_result(
530
+ e,
531
+ error_code=ErrorCode.DATABASE_QUERY_FAILED,
532
+ context=f"Failed to query votes with pagination for target {target_id}"
533
+ )