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.
- geek_cafe_saas_sdk/__init__.py +9 -0
- geek_cafe_saas_sdk/core/__init__.py +11 -0
- geek_cafe_saas_sdk/core/audit_mixin.py +33 -0
- geek_cafe_saas_sdk/core/error_codes.py +132 -0
- geek_cafe_saas_sdk/core/service_errors.py +19 -0
- geek_cafe_saas_sdk/core/service_result.py +121 -0
- geek_cafe_saas_sdk/decorators/__init__.py +64 -0
- geek_cafe_saas_sdk/decorators/auth.py +373 -0
- geek_cafe_saas_sdk/decorators/core.py +358 -0
- geek_cafe_saas_sdk/domains/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/analytics/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/analytics/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/analytics/models/__init__.py +9 -0
- geek_cafe_saas_sdk/domains/analytics/models/website_analytics.py +219 -0
- geek_cafe_saas_sdk/domains/analytics/models/website_analytics_summary.py +220 -0
- geek_cafe_saas_sdk/domains/analytics/services/__init__.py +11 -0
- geek_cafe_saas_sdk/domains/analytics/services/website_analytics_service.py +232 -0
- geek_cafe_saas_sdk/domains/analytics/services/website_analytics_summary_service.py +212 -0
- geek_cafe_saas_sdk/domains/analytics/services/website_analytics_tally_service.py +610 -0
- geek_cafe_saas_sdk/domains/auth/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/auth/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/create/app.py +41 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/delete/app.py +41 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/list/app.py +36 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/auth/models/__init__.py +13 -0
- geek_cafe_saas_sdk/domains/auth/models/permission.py +134 -0
- geek_cafe_saas_sdk/domains/auth/models/resource_permission.py +245 -0
- geek_cafe_saas_sdk/domains/auth/models/role.py +213 -0
- geek_cafe_saas_sdk/domains/auth/models/user.py +285 -0
- geek_cafe_saas_sdk/domains/auth/services/__init__.py +16 -0
- geek_cafe_saas_sdk/domains/auth/services/authorization_service.py +376 -0
- geek_cafe_saas_sdk/domains/auth/services/permission_registry.py +464 -0
- geek_cafe_saas_sdk/domains/auth/services/resource_permission_service.py +408 -0
- geek_cafe_saas_sdk/domains/auth/services/user_service.py +274 -0
- geek_cafe_saas_sdk/domains/communities/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/communities/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/create/app.py +41 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/delete/app.py +41 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/list/app.py +36 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/communities/models/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/communities/models/community.py +326 -0
- geek_cafe_saas_sdk/domains/communities/models/community_member.py +227 -0
- geek_cafe_saas_sdk/domains/communities/services/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/communities/services/community_member_service.py +412 -0
- geek_cafe_saas_sdk/domains/communities/services/community_service.py +479 -0
- geek_cafe_saas_sdk/domains/events/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/events/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/events/handlers/attendees/app.py +67 -0
- geek_cafe_saas_sdk/domains/events/handlers/cancel/app.py +66 -0
- geek_cafe_saas_sdk/domains/events/handlers/check_in/app.py +60 -0
- geek_cafe_saas_sdk/domains/events/handlers/create/app.py +93 -0
- geek_cafe_saas_sdk/domains/events/handlers/delete/app.py +42 -0
- geek_cafe_saas_sdk/domains/events/handlers/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/events/handlers/invite/app.py +98 -0
- geek_cafe_saas_sdk/domains/events/handlers/list/app.py +125 -0
- geek_cafe_saas_sdk/domains/events/handlers/publish/app.py +49 -0
- geek_cafe_saas_sdk/domains/events/handlers/rsvp/app.py +83 -0
- geek_cafe_saas_sdk/domains/events/handlers/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/events/models/__init__.py +3 -0
- geek_cafe_saas_sdk/domains/events/models/event.py +681 -0
- geek_cafe_saas_sdk/domains/events/models/event_attendee.py +324 -0
- geek_cafe_saas_sdk/domains/events/services/__init__.py +9 -0
- geek_cafe_saas_sdk/domains/events/services/event_attendee_service.py +571 -0
- geek_cafe_saas_sdk/domains/events/services/event_service.py +684 -0
- geek_cafe_saas_sdk/domains/files/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/files/models/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/files/models/directory.py +258 -0
- geek_cafe_saas_sdk/domains/files/models/file.py +312 -0
- geek_cafe_saas_sdk/domains/files/models/file_share.py +268 -0
- geek_cafe_saas_sdk/domains/files/models/file_version.py +216 -0
- geek_cafe_saas_sdk/domains/files/services/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/files/services/directory_service.py +701 -0
- geek_cafe_saas_sdk/domains/files/services/file_share_service.py +663 -0
- geek_cafe_saas_sdk/domains/files/services/file_system_service.py +575 -0
- geek_cafe_saas_sdk/domains/files/services/file_version_service.py +739 -0
- geek_cafe_saas_sdk/domains/files/services/s3_file_service.py +501 -0
- geek_cafe_saas_sdk/domains/messaging/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/create/app.py +86 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/delete/app.py +65 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/get/app.py +64 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/list/app.py +97 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/update/app.py +149 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/create/app.py +67 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/delete/app.py +65 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/get/app.py +64 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/list/app.py +102 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/update/app.py +127 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/create/app.py +94 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/delete/app.py +66 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/get/app.py +67 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/list/app.py +95 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/update/app.py +156 -0
- geek_cafe_saas_sdk/domains/messaging/models/__init__.py +13 -0
- geek_cafe_saas_sdk/domains/messaging/models/chat_channel.py +337 -0
- geek_cafe_saas_sdk/domains/messaging/models/chat_channel_member.py +180 -0
- geek_cafe_saas_sdk/domains/messaging/models/chat_message.py +426 -0
- geek_cafe_saas_sdk/domains/messaging/models/contact_thread.py +392 -0
- geek_cafe_saas_sdk/domains/messaging/services/__init__.py +11 -0
- geek_cafe_saas_sdk/domains/messaging/services/chat_channel_service.py +700 -0
- geek_cafe_saas_sdk/domains/messaging/services/chat_message_service.py +491 -0
- geek_cafe_saas_sdk/domains/messaging/services/contact_thread_service.py +497 -0
- geek_cafe_saas_sdk/domains/tenancy/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/activate/app.py +52 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/active/app.py +37 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/cancel/app.py +55 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/list/app.py +44 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/record_payment/app.py +56 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/me/app.py +37 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/signup/app.py +61 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/tenancy/models/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/tenancy/models/subscription.py +440 -0
- geek_cafe_saas_sdk/domains/tenancy/models/tenant.py +258 -0
- geek_cafe_saas_sdk/domains/tenancy/services/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/tenancy/services/subscription_service.py +557 -0
- geek_cafe_saas_sdk/domains/tenancy/services/tenant_service.py +575 -0
- geek_cafe_saas_sdk/domains/voting/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/voting/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/create/app.py +128 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/delete/app.py +41 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/list/app.py +38 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/summerize/README.md +3 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/voting/models/__init__.py +9 -0
- geek_cafe_saas_sdk/domains/voting/models/vote.py +231 -0
- geek_cafe_saas_sdk/domains/voting/models/vote_summary.py +193 -0
- geek_cafe_saas_sdk/domains/voting/services/__init__.py +11 -0
- geek_cafe_saas_sdk/domains/voting/services/vote_service.py +264 -0
- geek_cafe_saas_sdk/domains/voting/services/vote_summary_service.py +198 -0
- geek_cafe_saas_sdk/domains/voting/services/vote_tally_service.py +533 -0
- geek_cafe_saas_sdk/lambda_handlers/README.md +404 -0
- geek_cafe_saas_sdk/lambda_handlers/__init__.py +67 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/__init__.py +25 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/api_key_handler.py +129 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/authorized_secure_handler.py +218 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/base_handler.py +185 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/handler_factory.py +256 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/public_handler.py +53 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/secure_handler.py +89 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/service_pool.py +94 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/create/app.py +79 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/delete/app.py +76 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/get/app.py +74 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/list/app.py +75 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/move/app.py +79 -0
- geek_cafe_saas_sdk/lambda_handlers/files/delete/app.py +121 -0
- geek_cafe_saas_sdk/lambda_handlers/files/download/app.py +187 -0
- geek_cafe_saas_sdk/lambda_handlers/files/get/app.py +127 -0
- geek_cafe_saas_sdk/lambda_handlers/files/list/app.py +108 -0
- geek_cafe_saas_sdk/lambda_handlers/files/share/app.py +83 -0
- geek_cafe_saas_sdk/lambda_handlers/files/shares/list/app.py +84 -0
- geek_cafe_saas_sdk/lambda_handlers/files/shares/revoke/app.py +76 -0
- geek_cafe_saas_sdk/lambda_handlers/files/update/app.py +143 -0
- geek_cafe_saas_sdk/lambda_handlers/files/upload/app.py +151 -0
- geek_cafe_saas_sdk/middleware/__init__.py +36 -0
- geek_cafe_saas_sdk/middleware/auth.py +85 -0
- geek_cafe_saas_sdk/middleware/authorization.py +523 -0
- geek_cafe_saas_sdk/middleware/cors.py +63 -0
- geek_cafe_saas_sdk/middleware/error_handling.py +114 -0
- geek_cafe_saas_sdk/middleware/validation.py +80 -0
- geek_cafe_saas_sdk/models/__init__.py +20 -0
- geek_cafe_saas_sdk/models/base_model.py +233 -0
- geek_cafe_saas_sdk/services/__init__.py +18 -0
- geek_cafe_saas_sdk/services/database_service.py +441 -0
- geek_cafe_saas_sdk/utilities/__init__.py +88 -0
- geek_cafe_saas_sdk/utilities/cognito_utility.py +568 -0
- geek_cafe_saas_sdk/utilities/custom_exceptions.py +183 -0
- geek_cafe_saas_sdk/utilities/datetime_utility.py +410 -0
- geek_cafe_saas_sdk/utilities/dictionary_utility.py +78 -0
- geek_cafe_saas_sdk/utilities/dynamodb_utils.py +151 -0
- geek_cafe_saas_sdk/utilities/environment_loader.py +149 -0
- geek_cafe_saas_sdk/utilities/environment_variables.py +228 -0
- geek_cafe_saas_sdk/utilities/http_body_parameters.py +44 -0
- geek_cafe_saas_sdk/utilities/http_path_parameters.py +60 -0
- geek_cafe_saas_sdk/utilities/http_status_code.py +63 -0
- geek_cafe_saas_sdk/utilities/jwt_utility.py +234 -0
- geek_cafe_saas_sdk/utilities/lambda_event_utility.py +776 -0
- geek_cafe_saas_sdk/utilities/logging_utility.py +64 -0
- geek_cafe_saas_sdk/utilities/message_query_helper.py +340 -0
- geek_cafe_saas_sdk/utilities/response.py +209 -0
- geek_cafe_saas_sdk/utilities/string_functions.py +180 -0
- geek_cafe_saas_sdk-0.6.0.dist-info/METADATA +397 -0
- geek_cafe_saas_sdk-0.6.0.dist-info/RECORD +194 -0
- geek_cafe_saas_sdk-0.6.0.dist-info/WHEEL +4 -0
- 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
|
+
)
|