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,684 @@
1
+ # Event Service (Refactored for location-based discovery and timestamps)
2
+
3
+ from typing import Dict, Any, Optional, List, Tuple
4
+ from boto3_assist.dynamodb.dynamodb import DynamoDB
5
+ from geek_cafe_saas_sdk.services.database_service import DatabaseService
6
+ from geek_cafe_saas_sdk.core.service_result import ServiceResult
7
+ from geek_cafe_saas_sdk.core.service_errors import ValidationError, NotFoundError, AccessDeniedError
8
+ from geek_cafe_saas_sdk.domains.events.models import Event, EventAttendee
9
+ from geek_cafe_saas_sdk.utilities.dynamodb_utils import build_projection_with_reserved_keywords
10
+ import datetime as dt
11
+ import math
12
+
13
+
14
+ class EventService(DatabaseService[Event]):
15
+ """
16
+ Service for Event database operations (refactored for location-based discovery).
17
+
18
+ Features:
19
+ - Timestamp-based date fields (float UTC timestamps)
20
+ - Location-based queries (city, state, geo)
21
+ - Automatic EventAttendee creation for owner
22
+ - Capacity management
23
+ - Multi-host support via EventAttendee
24
+ """
25
+
26
+ def __init__(self, *, dynamodb: DynamoDB = None, table_name: str = None):
27
+ super().__init__(dynamodb=dynamodb, table_name=table_name)
28
+
29
+ def create(self, tenant_id: str, user_id: str, create_organizer_attendee: bool = True,
30
+ **kwargs) -> ServiceResult[Event]:
31
+ """
32
+ Create a new event.
33
+
34
+ Args:
35
+ tenant_id: Tenant ID
36
+ user_id: User ID (becomes owner_id)
37
+ create_organizer_attendee: Auto-create EventAttendee for organizer
38
+ **kwargs: Event fields
39
+
40
+ Required kwargs: title, start_utc_ts (or start_datetime)
41
+ """
42
+ try:
43
+ # Validate required fields
44
+ if not kwargs.get('title'):
45
+ raise ValidationError("Title is required")
46
+
47
+ # Handle datetime conversion (support multiple field names)
48
+ start_utc_ts = kwargs.get('start_utc_ts')
49
+ if not start_utc_ts and kwargs.get('start_datetime'):
50
+ start_utc_ts = Event.datetime_to_utc_ts(kwargs['start_datetime'])
51
+ kwargs['start_utc_ts'] = start_utc_ts
52
+ elif not start_utc_ts and kwargs.get('date'):
53
+ # Support legacy 'date' field
54
+ start_utc_ts = Event.datetime_to_utc_ts(kwargs['date'])
55
+ kwargs['start_utc_ts'] = start_utc_ts
56
+
57
+ if not start_utc_ts:
58
+ raise ValidationError("start_utc_ts or start_datetime or date is required")
59
+
60
+ # Validate date is in future (only skip for explicitly draft status)
61
+ status = kwargs.get('status')
62
+ # Only allow past dates if status is explicitly set to 'draft'
63
+ if status != 'draft' and start_utc_ts < dt.datetime.now(dt.UTC).timestamp():
64
+ raise ValidationError("Event start time must be in the future")
65
+
66
+ # Create event instance
67
+ event = Event().map(kwargs)
68
+ event.owner_id = user_id # Primary owner
69
+ event.tenant_id = tenant_id
70
+ event.user_id = user_id
71
+ event.created_by_id = user_id
72
+
73
+ # Set defaults
74
+ if not event.status:
75
+ event.status = 'draft'
76
+ if not event.visibility:
77
+ event.visibility = 'public'
78
+
79
+ # Prepare for save
80
+ event.prep_for_save()
81
+
82
+ # Save to database
83
+ save_result = self._save_model(event)
84
+
85
+ if save_result.success and create_organizer_attendee:
86
+ # Create EventAttendee record for organizer
87
+ from .event_attendee_service import EventAttendeeService
88
+ attendee_service = EventAttendeeService(dynamodb=self.dynamodb, table_name=self.table_name)
89
+
90
+ attendee_result = attendee_service.invite(
91
+ event_id=event.id,
92
+ user_id=user_id,
93
+ tenant_id=tenant_id,
94
+ invited_by_user_id=user_id,
95
+ role='organizer',
96
+ rsvp_status='accepted'
97
+ )
98
+
99
+ # Don't fail event creation if attendee creation fails
100
+ if not attendee_result.success:
101
+ print(f"Warning: Failed to create attendee record for organizer: {attendee_result.error}")
102
+
103
+ return save_result
104
+
105
+ except Exception as e:
106
+ return self._handle_service_exception(e, 'create_event', tenant_id=tenant_id, user_id=user_id)
107
+
108
+ def get_by_id(self, resource_id: str, tenant_id: str, user_id: str) -> ServiceResult[Event]:
109
+ """Get event by ID with access control."""
110
+ try:
111
+ event = self._get_model_by_id(resource_id, Event)
112
+
113
+ if not event:
114
+ raise NotFoundError(f"Event with ID {resource_id} not found")
115
+
116
+ # Check if deleted
117
+ if event.is_deleted():
118
+ raise NotFoundError(f"Event with ID {resource_id} not found")
119
+
120
+ # Validate tenant access
121
+ if hasattr(event, 'tenant_id'):
122
+ self._validate_tenant_access(event.tenant_id, tenant_id)
123
+
124
+ return ServiceResult.success_result(event)
125
+
126
+ except Exception as e:
127
+ return self._handle_service_exception(e, 'get_event', resource_id=resource_id, tenant_id=tenant_id)
128
+
129
+ def list_by_owner(self, owner_id: str, tenant_id: str, limit: int = 50) -> ServiceResult[List[Event]]:
130
+ """
131
+ Get events by owner (primary organizer) using GSI1.
132
+
133
+ Args:
134
+ owner_id: Owner user ID
135
+ tenant_id: Tenant ID
136
+ limit: Max results
137
+ """
138
+ try:
139
+ temp_event = Event()
140
+ temp_event.owner_id = owner_id
141
+
142
+ result = self._query_by_index(
143
+ temp_event,
144
+ "gsi1",
145
+ ascending=False, # Most recent first
146
+ limit=limit
147
+ )
148
+
149
+ if not result.success:
150
+ return result
151
+
152
+ # Filter out deleted events and validate tenant
153
+ active_events = []
154
+ for event in result.data:
155
+ if not event.is_deleted() and event.tenant_id == tenant_id:
156
+ active_events.append(event)
157
+
158
+ return ServiceResult.success_result(active_events)
159
+
160
+ except Exception as e:
161
+ return self._handle_service_exception(e, 'list_by_owner', owner_id=owner_id)
162
+
163
+ def list_by_city(self, city: str, state: str, country: str, tenant_id: str,
164
+ start_date: float = None, end_date: float = None,
165
+ limit: int = 50) -> ServiceResult[List[Event]]:
166
+ """
167
+ Get events by city using GSI2 (city#state#country).
168
+
169
+ Args:
170
+ city: City name
171
+ state: State/Province/Region
172
+ country: Country code
173
+ tenant_id: Tenant ID
174
+ start_date: Optional start date filter (UTC timestamp)
175
+ end_date: Optional end date filter (UTC timestamp)
176
+ limit: Max results
177
+ """
178
+ try:
179
+ temp_event = Event()
180
+ temp_event.location_city = city
181
+ temp_event.location_state = state
182
+ temp_event.location_country = country
183
+
184
+ # Use helper method for query
185
+ result = self._query_by_index(
186
+ model=temp_event,
187
+ index_name="gsi2",
188
+ ascending=True, # Upcoming events first
189
+ limit=limit
190
+ )
191
+
192
+ if not result.success:
193
+ return result
194
+
195
+ # Post-query filtering
196
+ valid_events = []
197
+ for event in result.data:
198
+ # Tenant isolation and deleted check
199
+ if event.tenant_id != tenant_id or event.is_deleted():
200
+ continue
201
+
202
+ # Date range filter if specified
203
+ if start_date and end_date:
204
+ if not (start_date <= event.start_utc_ts <= end_date):
205
+ continue
206
+
207
+ valid_events.append(event)
208
+
209
+ return ServiceResult.success_result(valid_events)
210
+
211
+ except Exception as e:
212
+ return self._handle_service_exception(e, 'list_by_city', city=city, state=state, country=country)
213
+
214
+ def list_by_state(self, state: str, country: str, tenant_id: str,
215
+ limit: int = 50) -> ServiceResult[List[Event]]:
216
+ """
217
+ Get events by state/region using GSI3.
218
+
219
+ Args:
220
+ state: State/Province/Region
221
+ country: Country code
222
+ tenant_id: Tenant ID
223
+ limit: Max results
224
+ """
225
+ try:
226
+ temp_event = Event()
227
+ temp_event.location_state = state
228
+ temp_event.location_country = country
229
+
230
+ # Use helper method for query
231
+ result = self._query_by_index(
232
+ model=temp_event,
233
+ index_name="gsi3",
234
+ ascending=True,
235
+ limit=limit
236
+ )
237
+
238
+ if not result.success:
239
+ return result
240
+
241
+ # Post-query filtering
242
+ valid_events = [
243
+ event for event in result.data
244
+ if event.tenant_id == tenant_id and not event.is_deleted()
245
+ ]
246
+
247
+ return ServiceResult.success_result(valid_events)
248
+
249
+ except Exception as e:
250
+ return self._handle_service_exception(e, 'list_by_state', state=state, country=country)
251
+
252
+ def list_nearby(self, latitude: float, longitude: float, radius_km: float,
253
+ tenant_id: str, limit: int = 50) -> ServiceResult[List[Event]]:
254
+ """
255
+ Get events near a location using GSI4 (geo-location).
256
+
257
+ Uses geohash for approximate location then filters by actual distance.
258
+
259
+ Args:
260
+ latitude: Latitude
261
+ longitude: Longitude
262
+ radius_km: Search radius in kilometers
263
+ tenant_id: Tenant ID
264
+ limit: Max results
265
+ """
266
+ try:
267
+ # Create temp event to get geohash
268
+ temp_event = Event()
269
+ temp_event.location_latitude = latitude
270
+ temp_event.location_longitude = longitude
271
+
272
+ geohash = temp_event._get_geohash()
273
+ if not geohash:
274
+ return ServiceResult.error_result("Invalid coordinates", "INVALID_COORDINATES")
275
+
276
+ # Use helper method for query (get more results for distance filtering)
277
+ result = self._query_by_index(
278
+ model=temp_event,
279
+ index_name="gsi4",
280
+ ascending=True,
281
+ limit=limit * 3 # Get extra to filter by distance
282
+ )
283
+
284
+ if not result.success:
285
+ return result
286
+
287
+ # Filter by actual distance and tenant
288
+ nearby_events = []
289
+ for event in result.data:
290
+ # Tenant isolation and deleted check
291
+ if event.tenant_id != tenant_id or event.is_deleted():
292
+ continue
293
+
294
+ if event.has_location_coordinates():
295
+ distance = self._calculate_distance(
296
+ latitude, longitude,
297
+ event.location_latitude, event.location_longitude
298
+ )
299
+ if distance <= radius_km:
300
+ nearby_events.append(event)
301
+ if len(nearby_events) >= limit:
302
+ break
303
+
304
+ return ServiceResult.success_result(nearby_events)
305
+
306
+ except Exception as e:
307
+ return self._handle_service_exception(e, 'list_nearby', latitude=latitude, longitude=longitude)
308
+
309
+ def list_by_type(self, event_type: str, tenant_id: str, status: str = 'published',
310
+ limit: int = 50) -> ServiceResult[List[Event]]:
311
+ """
312
+ Get events by type using GSI6.
313
+
314
+ Args:
315
+ event_type: Event type (meetup, conference, etc.)
316
+ tenant_id: Tenant ID
317
+ status: Event status filter
318
+ limit: Max results
319
+ """
320
+ try:
321
+ temp_event = Event()
322
+ temp_event.event_type = event_type
323
+ temp_event.status = status
324
+
325
+ # Use helper method for query
326
+ result = self._query_by_index(
327
+ model=temp_event,
328
+ index_name="gsi6",
329
+ ascending=True,
330
+ limit=limit
331
+ )
332
+
333
+ if not result.success:
334
+ return result
335
+
336
+ # Post-query filtering
337
+ valid_events = [
338
+ event for event in result.data
339
+ if event.tenant_id == tenant_id and not event.is_deleted()
340
+ ]
341
+
342
+ return ServiceResult.success_result(valid_events)
343
+
344
+ except Exception as e:
345
+ return self._handle_service_exception(e, 'list_by_type', event_type=event_type)
346
+
347
+ def list_by_group(self, group_id: str, tenant_id: str, limit: int = 50) -> ServiceResult[List[Event]]:
348
+ """
349
+ Get events for a group using GSI7.
350
+
351
+ Args:
352
+ group_id: Group ID
353
+ tenant_id: Tenant ID
354
+ limit: Max results
355
+ """
356
+ try:
357
+ temp_event = Event()
358
+ temp_event.group_id = group_id
359
+
360
+ result = self._query_by_index(
361
+ temp_event,
362
+ "gsi7",
363
+ ascending=False,
364
+ limit=limit
365
+ )
366
+
367
+ if not result.success:
368
+ return result
369
+
370
+ # Filter tenant and deleted
371
+ active_events = []
372
+ for event in result.data:
373
+ if not event.is_deleted() and event.tenant_id == tenant_id:
374
+ active_events.append(event)
375
+
376
+ return ServiceResult.success_result(active_events)
377
+
378
+ except Exception as e:
379
+ return self._handle_service_exception(e, 'list_by_group', group_id=group_id)
380
+
381
+ def list_public_events(self, tenant_id: str, visibility: str = 'public',
382
+ status: str = 'published', limit: int = 50) -> ServiceResult[List[Event]]:
383
+ """
384
+ Get public published events using GSI8 (discovery feed).
385
+
386
+ Args:
387
+ tenant_id: Tenant ID
388
+ visibility: Visibility filter (public, private, members_only)
389
+ status: Status filter (published, draft, etc.)
390
+ limit: Max results
391
+ """
392
+ try:
393
+ temp_event = Event()
394
+ temp_event.visibility = visibility
395
+ temp_event.status = status
396
+
397
+ # Use helper method for query
398
+ result = self._query_by_index(
399
+ model=temp_event,
400
+ index_name="gsi8",
401
+ ascending=True, # Upcoming first
402
+ limit=limit
403
+ )
404
+
405
+ if not result.success:
406
+ return result
407
+
408
+ # Post-query filtering
409
+ valid_events = [
410
+ event for event in result.data
411
+ if event.tenant_id == tenant_id and not event.is_deleted()
412
+ ]
413
+
414
+ return ServiceResult.success_result(valid_events)
415
+
416
+ except Exception as e:
417
+ return self._handle_service_exception(e, 'list_public_events', visibility=visibility, status=status)
418
+
419
+ def list_by_tenant(self, tenant_id: str, user_id: str = None, limit: int = 50) -> ServiceResult[List[Event]]:
420
+ """
421
+ Get all events for a tenant using GSI5.
422
+
423
+ Args:
424
+ tenant_id: Tenant ID
425
+ user_id: User ID (optional, for access control)
426
+ limit: Max results
427
+ """
428
+ try:
429
+ temp_event = Event()
430
+ temp_event.tenant_id = tenant_id
431
+
432
+ result = self._query_by_index(
433
+ temp_event,
434
+ "gsi5",
435
+ ascending=False,
436
+ limit=limit
437
+ )
438
+
439
+ if not result.success:
440
+ return result
441
+
442
+ # Filter deleted
443
+ active_events = []
444
+ for event in result.data:
445
+ if not event.is_deleted():
446
+ active_events.append(event)
447
+
448
+ return ServiceResult.success_result(active_events)
449
+
450
+ except Exception as e:
451
+ return self._handle_service_exception(e, 'list_by_tenant', tenant_id=tenant_id)
452
+
453
+ def update(self, resource_id: str, tenant_id: str, user_id: str,
454
+ updates: Dict[str, Any]) -> ServiceResult[Event]:
455
+ """
456
+ Update event with access control.
457
+
458
+ Args:
459
+ resource_id: Event ID
460
+ tenant_id: Tenant ID
461
+ user_id: User making update
462
+ updates: Fields to update
463
+ """
464
+ try:
465
+ # Get existing event
466
+ event = self._get_model_by_id(resource_id, Event)
467
+
468
+ if not event:
469
+ raise NotFoundError(f"Event with ID {resource_id} not found")
470
+
471
+ # Validate tenant access
472
+ if hasattr(event, 'tenant_id'):
473
+ self._validate_tenant_access(event.tenant_id, tenant_id)
474
+
475
+ # Check permissions (owner or host)
476
+ if not (event.owner_id == user_id or self._is_host(resource_id, user_id, tenant_id)):
477
+ raise AccessDeniedError("Access denied: only event owner/hosts can update")
478
+
479
+ # Handle datetime conversions
480
+ if 'start_datetime' in updates and 'start_utc_ts' not in updates:
481
+ updates['start_utc_ts'] = Event.datetime_to_utc_ts(updates['start_datetime'])
482
+ if 'end_datetime' in updates and 'end_utc_ts' not in updates:
483
+ updates['end_utc_ts'] = Event.datetime_to_utc_ts(updates['end_datetime'])
484
+
485
+ # Validate dates if updating
486
+ if 'start_utc_ts' in updates:
487
+ if event.status != 'draft' and updates['start_utc_ts'] < dt.datetime.now(dt.UTC).timestamp():
488
+ raise ValidationError("Event start time must be in the future")
489
+
490
+ # Block group_id changes after creation
491
+ if 'group_id' in updates and event.group_id and updates['group_id'] != event.group_id:
492
+ raise ValidationError("Cannot change group_id after event creation")
493
+
494
+ # Apply updates using map()
495
+ event = event.map(updates)
496
+
497
+ # Update metadata
498
+ event.updated_by_id = user_id
499
+ event.prep_for_save()
500
+
501
+ # Save
502
+ return self._save_model(event)
503
+
504
+ except Exception as e:
505
+ return self._handle_service_exception(e, 'update_event', resource_id=resource_id)
506
+
507
+ def delete(self, resource_id: str, tenant_id: str, user_id: str) -> ServiceResult[bool]:
508
+ """
509
+ Soft delete event with access control.
510
+
511
+ Args:
512
+ resource_id: Event ID
513
+ tenant_id: Tenant ID
514
+ user_id: User deleting event
515
+ """
516
+ try:
517
+ event = self._get_model_by_id(resource_id, Event)
518
+
519
+ if not event:
520
+ raise NotFoundError(f"Event with ID {resource_id} not found")
521
+
522
+ if event.is_deleted():
523
+ return ServiceResult.success_result(True)
524
+
525
+ # Validate tenant
526
+ if hasattr(event, 'tenant_id'):
527
+ self._validate_tenant_access(event.tenant_id, tenant_id)
528
+
529
+ # Check permissions
530
+ if not (event.owner_id == user_id or self._is_host(resource_id, user_id, tenant_id)):
531
+ raise AccessDeniedError("Access denied: only event owner/hosts can delete")
532
+
533
+ # Soft delete
534
+ event.deleted_utc_ts = dt.datetime.now(dt.UTC).timestamp()
535
+ event.deleted_by_id = user_id
536
+ event.prep_for_save()
537
+
538
+ save_result = self._save_model(event)
539
+ return ServiceResult.success_result(save_result.success) if save_result.success else save_result
540
+
541
+ except Exception as e:
542
+ return self._handle_service_exception(e, 'delete_event', resource_id=resource_id)
543
+
544
+ def publish(self, resource_id: str, tenant_id: str, user_id: str) -> ServiceResult[Event]:
545
+ """
546
+ Publish a draft event.
547
+
548
+ Args:
549
+ resource_id: Event ID
550
+ tenant_id: Tenant ID
551
+ user_id: User publishing
552
+ """
553
+ try:
554
+ return self.update(resource_id, tenant_id, user_id, {'status': 'published'})
555
+ except Exception as e:
556
+ return self._handle_service_exception(e, 'publish_event', resource_id=resource_id)
557
+
558
+ def cancel(self, resource_id: str, tenant_id: str, user_id: str,
559
+ cancellation_reason: str = None) -> ServiceResult[Event]:
560
+ """
561
+ Cancel an event.
562
+
563
+ Args:
564
+ resource_id: Event ID
565
+ tenant_id: Tenant ID
566
+ user_id: User cancelling
567
+ cancellation_reason: Reason for cancellation
568
+ """
569
+ try:
570
+ updates = {'status': 'cancelled'}
571
+ if cancellation_reason:
572
+ updates['cancellation_reason'] = cancellation_reason
573
+
574
+ return self.update(resource_id, tenant_id, user_id, updates)
575
+ except Exception as e:
576
+ return self._handle_service_exception(e, 'cancel_event', resource_id=resource_id)
577
+
578
+ def create_draft(self, tenant_id: str, user_id: str, **kwargs) -> ServiceResult[Event]:
579
+ """
580
+ Create a draft event with minimal required fields.
581
+ Auto-generates a future date if not provided.
582
+
583
+ Args:
584
+ tenant_id: Tenant ID
585
+ user_id: User ID
586
+ **kwargs: Optional event fields
587
+ """
588
+ # Ensure status is draft
589
+ kwargs['status'] = 'draft'
590
+
591
+ # Auto-generate start date if not provided (1 week from now)
592
+ if not kwargs.get('start_utc_ts') and not kwargs.get('start_datetime') and not kwargs.get('date'):
593
+ future_date = dt.datetime.now(dt.UTC) + dt.timedelta(days=7)
594
+ kwargs['start_utc_ts'] = future_date.timestamp()
595
+
596
+ # Use the standard create method (draft events don't require date validation)
597
+ return self.create(tenant_id, user_id, **kwargs)
598
+
599
+ def get_events_by_organizer(self, organizer_id: str, tenant_id: str, user_id: str,
600
+ limit: int = 50) -> ServiceResult[List[Event]]:
601
+ """
602
+ Get events by organizer ID (uses owner_id field).
603
+ Alias for list_by_owner for backward compatibility.
604
+
605
+ Args:
606
+ organizer_id: Organizer user ID
607
+ tenant_id: Tenant ID
608
+ user_id: Requesting user ID
609
+ limit: Max results
610
+ """
611
+ return self.list_by_owner(organizer_id, tenant_id, limit)
612
+
613
+ def get_events_by_group(self, group_id: str, tenant_id: str, user_id: str,
614
+ limit: int = 50) -> ServiceResult[List[Event]]:
615
+ """
616
+ Get events by group ID.
617
+ Wrapper for list_by_group for backward compatibility.
618
+
619
+ Args:
620
+ group_id: Group ID
621
+ tenant_id: Tenant ID
622
+ user_id: Requesting user ID
623
+ limit: Max results
624
+ """
625
+ return self.list_by_group(group_id, tenant_id, limit)
626
+
627
+ def get_upcoming_events(self, tenant_id: str, user_id: str, limit: int = 50) -> ServiceResult[List[Event]]:
628
+ """
629
+ Get upcoming events for a tenant (events starting in the future).
630
+
631
+ Args:
632
+ tenant_id: Tenant ID
633
+ user_id: Requesting user ID
634
+ limit: Max results
635
+ """
636
+ try:
637
+ # Get all events for tenant
638
+ result = self.list_by_tenant(tenant_id, limit=limit)
639
+
640
+ if not result.success:
641
+ return result
642
+
643
+ # Filter to only upcoming events
644
+ now_ts = dt.datetime.now(dt.UTC).timestamp()
645
+ upcoming_events = [
646
+ event for event in result.data
647
+ if event.start_utc_ts and event.start_utc_ts > now_ts
648
+ ]
649
+
650
+ # Sort by start date (soonest first)
651
+ upcoming_events.sort(key=lambda e: e.start_utc_ts or 0)
652
+
653
+ return ServiceResult.success_result(upcoming_events)
654
+
655
+ except Exception as e:
656
+ return self._handle_service_exception(e, 'get_upcoming_events', tenant_id=tenant_id)
657
+
658
+ # Helper Methods
659
+ def _is_host(self, event_id: str, user_id: str, tenant_id: str) -> bool:
660
+ """Check if user is a host (organizer or co-host) for event."""
661
+ try:
662
+ from .event_attendee_service import EventAttendeeService
663
+ attendee_service = EventAttendeeService(dynamodb=self.dynamodb, table_name=self.table_name)
664
+
665
+ result = attendee_service.get_attendee(event_id, user_id, tenant_id)
666
+ if result.success:
667
+ return result.data.is_host()
668
+ return False
669
+ except:
670
+ return False
671
+
672
+ def _calculate_distance(self, lat1: float, lng1: float, lat2: float, lng2: float) -> float:
673
+ """Calculate distance between two coordinates in kilometers (Haversine formula)."""
674
+ R = 6371 # Earth's radius in km
675
+
676
+ lat1_rad = math.radians(lat1)
677
+ lat2_rad = math.radians(lat2)
678
+ delta_lat = math.radians(lat2 - lat1)
679
+ delta_lng = math.radians(lng2 - lng1)
680
+
681
+ a = math.sin(delta_lat / 2) ** 2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(delta_lng / 2) ** 2
682
+ c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
683
+
684
+ return R * c
File without changes
File without changes