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,227 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Geek Cafe, LLC
|
|
3
|
+
MIT License. See Project Root for the license information.
|
|
4
|
+
|
|
5
|
+
CommunityMember model for scalable community membership using single-table design.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from boto3_assist.dynamodb.dynamodb_index import DynamoDBIndex, DynamoDBKey
|
|
9
|
+
import datetime as dt
|
|
10
|
+
from typing import Optional
|
|
11
|
+
from geek_cafe_saas_sdk.models.base_model import BaseModel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CommunityMember(BaseModel):
|
|
15
|
+
"""
|
|
16
|
+
CommunityMember model for scalable membership tracking.
|
|
17
|
+
|
|
18
|
+
Single-table design pattern:
|
|
19
|
+
- PK: community#<community_id>
|
|
20
|
+
- SK: member#<user_id>
|
|
21
|
+
- GSI1PK: user#<user_id> (for "what communities am I in?")
|
|
22
|
+
- GSI1SK: community#<community_id>#<joined_at>
|
|
23
|
+
|
|
24
|
+
Benefits:
|
|
25
|
+
- Unlimited members per community
|
|
26
|
+
- Fast membership checks (single GetItem)
|
|
27
|
+
- Member metadata (role, status, join date)
|
|
28
|
+
- Efficient pagination
|
|
29
|
+
- Concurrent updates without contention
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self):
|
|
33
|
+
super().__init__()
|
|
34
|
+
# Note: Uses normal UUID id, but pk/sk create adjacent record pattern
|
|
35
|
+
self._community_id: str | None = None
|
|
36
|
+
self._user_id: str | None = None
|
|
37
|
+
|
|
38
|
+
# Member metadata
|
|
39
|
+
self._status: str = "active" # active, pending, banned, left
|
|
40
|
+
self._joined_at: float | None = None
|
|
41
|
+
self._invited_by_id: str | None = None
|
|
42
|
+
|
|
43
|
+
# Dues tracking
|
|
44
|
+
self._dues_status: str = "current" # current, overdue, exempt, pending
|
|
45
|
+
self._last_payment_at: float | None = None
|
|
46
|
+
self._next_payment_due: float | None = None
|
|
47
|
+
|
|
48
|
+
# Activity tracking
|
|
49
|
+
self._last_active_at: float | None = None
|
|
50
|
+
self._event_attendance_count: int = 0
|
|
51
|
+
|
|
52
|
+
# Setup DynamoDB indexes (adjacent record pattern)
|
|
53
|
+
self._setup_indexes()
|
|
54
|
+
|
|
55
|
+
def _setup_indexes(self):
|
|
56
|
+
"""Setup DynamoDB indexes for member queries."""
|
|
57
|
+
|
|
58
|
+
# Primary index: Adjacent record pattern - members grouped by community
|
|
59
|
+
# Query: pk="community#<id>" AND sk BEGINS_WITH "member#" to get all members
|
|
60
|
+
primary: DynamoDBIndex = DynamoDBIndex()
|
|
61
|
+
primary.name = "primary"
|
|
62
|
+
primary.partition_key.attribute_name = "pk"
|
|
63
|
+
primary.partition_key.value = lambda: DynamoDBKey.build_key(("community", self.community_id))
|
|
64
|
+
primary.sort_key.attribute_name = "sk"
|
|
65
|
+
primary.sort_key.value = lambda: DynamoDBKey.build_key(("member", self.user_id))
|
|
66
|
+
self.indexes.add_primary(primary)
|
|
67
|
+
|
|
68
|
+
# GSI1: User's communities - query communities by user
|
|
69
|
+
gsi: DynamoDBIndex = DynamoDBIndex()
|
|
70
|
+
gsi.name = "gsi1"
|
|
71
|
+
gsi.partition_key.attribute_name = f"{gsi.name}_pk"
|
|
72
|
+
gsi.partition_key.value = lambda: DynamoDBKey.build_key(("user", self.user_id))
|
|
73
|
+
gsi.sort_key.attribute_name = f"{gsi.name}_sk"
|
|
74
|
+
gsi.sort_key.value = lambda: DynamoDBKey.build_key(("community", self.community_id), ("ts", self.joined_at))
|
|
75
|
+
self.indexes.add_secondary(gsi)
|
|
76
|
+
|
|
77
|
+
# GSI2: Members by status - query members by status within community
|
|
78
|
+
gsi: DynamoDBIndex = DynamoDBIndex()
|
|
79
|
+
gsi.name = "gsi2"
|
|
80
|
+
gsi.partition_key.attribute_name = f"{gsi.name}_pk"
|
|
81
|
+
gsi.partition_key.value = lambda: DynamoDBKey.build_key(("community", self.community_id), ("status", self.status))
|
|
82
|
+
gsi.sort_key.attribute_name = f"{gsi.name}_sk"
|
|
83
|
+
gsi.sort_key.value = lambda: DynamoDBKey.build_key(("member", self.user_id))
|
|
84
|
+
self.indexes.add_secondary(gsi)
|
|
85
|
+
|
|
86
|
+
# GSI3: Members by dues status - query members with overdue dues
|
|
87
|
+
gsi: DynamoDBIndex = DynamoDBIndex()
|
|
88
|
+
gsi.name = "gsi3"
|
|
89
|
+
gsi.partition_key.attribute_name = f"{gsi.name}_pk"
|
|
90
|
+
gsi.partition_key.value = lambda: DynamoDBKey.build_key(("community", self.community_id), ("dues", self.dues_status))
|
|
91
|
+
gsi.sort_key.attribute_name = f"{gsi.name}_sk"
|
|
92
|
+
gsi.sort_key.value = lambda: DynamoDBKey.build_key(("due_date", self.next_payment_due or 0))
|
|
93
|
+
self.indexes.add_secondary(gsi)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def model_version(self) -> str:
|
|
97
|
+
return "1.0"
|
|
98
|
+
|
|
99
|
+
def prep_for_save(self):
|
|
100
|
+
"""
|
|
101
|
+
Prepare member record for save.
|
|
102
|
+
|
|
103
|
+
Note: CommunityMember uses adjacent record pattern where:
|
|
104
|
+
- Normal UUID id (generated by parent)
|
|
105
|
+
- pk = "community#<community_id>" (groups by community)
|
|
106
|
+
- sk = "member#<user_id>" (enables begins_with queries)
|
|
107
|
+
"""
|
|
108
|
+
# Validate required fields for adjacent record pattern
|
|
109
|
+
if not self.community_id:
|
|
110
|
+
raise ValueError("community_id is required for CommunityMember")
|
|
111
|
+
if not self.user_id:
|
|
112
|
+
raise ValueError("user_id is required for CommunityMember")
|
|
113
|
+
|
|
114
|
+
# Set joined_at if not already set
|
|
115
|
+
if not self.joined_at:
|
|
116
|
+
self.joined_at = dt.datetime.now(dt.UTC).timestamp()
|
|
117
|
+
|
|
118
|
+
# Call parent to generate normal UUID id and set timestamps
|
|
119
|
+
super().prep_for_save()
|
|
120
|
+
|
|
121
|
+
# Properties
|
|
122
|
+
@property
|
|
123
|
+
def community_id(self) -> str:
|
|
124
|
+
return self._community_id
|
|
125
|
+
|
|
126
|
+
@community_id.setter
|
|
127
|
+
def community_id(self, value: str | None):
|
|
128
|
+
self._community_id = value
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def user_id(self) -> str:
|
|
132
|
+
return self._user_id
|
|
133
|
+
|
|
134
|
+
@user_id.setter
|
|
135
|
+
def user_id(self, value: str | None):
|
|
136
|
+
self._user_id = value
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def status(self) -> str:
|
|
140
|
+
return self._status
|
|
141
|
+
|
|
142
|
+
@status.setter
|
|
143
|
+
def status(self, value: str | None):
|
|
144
|
+
valid_statuses = ["active", "pending", "banned", "left"]
|
|
145
|
+
self._status = value if value in valid_statuses else "active"
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def joined_at(self) -> float:
|
|
149
|
+
return self._joined_at
|
|
150
|
+
|
|
151
|
+
@joined_at.setter
|
|
152
|
+
def joined_at(self, value: float | None):
|
|
153
|
+
self._joined_at = value
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def invited_by_id(self) -> str:
|
|
157
|
+
return self._invited_by_id
|
|
158
|
+
|
|
159
|
+
@invited_by_id.setter
|
|
160
|
+
def invited_by_id(self, value: str | None):
|
|
161
|
+
self._invited_by_id = value
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def dues_status(self) -> str:
|
|
165
|
+
return self._dues_status
|
|
166
|
+
|
|
167
|
+
@dues_status.setter
|
|
168
|
+
def dues_status(self, value: str | None):
|
|
169
|
+
valid_statuses = ["current", "overdue", "exempt", "pending"]
|
|
170
|
+
self._dues_status = value if value in valid_statuses else "current"
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def last_payment_at(self) -> float:
|
|
174
|
+
return self._last_payment_at
|
|
175
|
+
|
|
176
|
+
@last_payment_at.setter
|
|
177
|
+
def last_payment_at(self, value: float | None):
|
|
178
|
+
self._last_payment_at = value
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def next_payment_due(self) -> float:
|
|
182
|
+
return self._next_payment_due
|
|
183
|
+
|
|
184
|
+
@next_payment_due.setter
|
|
185
|
+
def next_payment_due(self, value: float | None):
|
|
186
|
+
self._next_payment_due = value
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def last_active_at(self) -> float:
|
|
190
|
+
return self._last_active_at
|
|
191
|
+
|
|
192
|
+
@last_active_at.setter
|
|
193
|
+
def last_active_at(self, value: float | None):
|
|
194
|
+
self._last_active_at = value
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def event_attendance_count(self) -> int:
|
|
198
|
+
return self._event_attendance_count
|
|
199
|
+
|
|
200
|
+
@event_attendance_count.setter
|
|
201
|
+
def event_attendance_count(self, value: int | None):
|
|
202
|
+
self._event_attendance_count = value if isinstance(value, int) and value >= 0 else 0
|
|
203
|
+
|
|
204
|
+
# Helper methods
|
|
205
|
+
def is_active(self) -> bool:
|
|
206
|
+
"""Check if member is active."""
|
|
207
|
+
return self.status == "active"
|
|
208
|
+
|
|
209
|
+
def is_pending(self) -> bool:
|
|
210
|
+
"""Check if membership is pending approval."""
|
|
211
|
+
return self.status == "pending"
|
|
212
|
+
|
|
213
|
+
def is_banned(self) -> bool:
|
|
214
|
+
"""Check if member is banned."""
|
|
215
|
+
return self.status == "banned"
|
|
216
|
+
|
|
217
|
+
def has_left(self) -> bool:
|
|
218
|
+
"""Check if member has left the community."""
|
|
219
|
+
return self.status == "left"
|
|
220
|
+
|
|
221
|
+
def is_dues_current(self) -> bool:
|
|
222
|
+
"""Check if member's dues are current."""
|
|
223
|
+
return self.dues_status == "current" or self.dues_status == "exempt"
|
|
224
|
+
|
|
225
|
+
def is_dues_overdue(self) -> bool:
|
|
226
|
+
"""Check if member's dues are overdue."""
|
|
227
|
+
return self.dues_status == "overdue"
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# Community Member Service
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Any, List, Optional
|
|
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.communities.models import CommunityMember
|
|
9
|
+
import datetime as dt
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CommunityMemberService(DatabaseService[CommunityMember]):
|
|
13
|
+
"""
|
|
14
|
+
Service for CommunityMember operations.
|
|
15
|
+
|
|
16
|
+
Manages scalable membership using adjacent record pattern.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, *, dynamodb: DynamoDB = None, table_name: str = None):
|
|
20
|
+
super().__init__(dynamodb=dynamodb, table_name=table_name)
|
|
21
|
+
|
|
22
|
+
# Required abstract methods from DatabaseService
|
|
23
|
+
def create(self, tenant_id: str, user_id: str, **kwargs) -> ServiceResult[CommunityMember]:
|
|
24
|
+
"""Create method - delegates to add_member()."""
|
|
25
|
+
if 'community_id' not in kwargs:
|
|
26
|
+
return ServiceResult.error_result("community_id is required", "VALIDATION_ERROR")
|
|
27
|
+
|
|
28
|
+
return self.add_member(
|
|
29
|
+
community_id=kwargs['community_id'],
|
|
30
|
+
user_id=user_id,
|
|
31
|
+
invited_by_id=kwargs.get('invited_by_id'),
|
|
32
|
+
status=kwargs.get('status', 'active')
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def get_by_id(self, resource_id: str, tenant_id: str, user_id: str) -> ServiceResult[CommunityMember]:
|
|
36
|
+
"""Get method - resource_id should be in format 'community_id:user_id'."""
|
|
37
|
+
try:
|
|
38
|
+
if ':' in resource_id:
|
|
39
|
+
community_id, member_user_id = resource_id.split(':', 1)
|
|
40
|
+
return self.get_membership(community_id, member_user_id)
|
|
41
|
+
return ServiceResult.error_result("Invalid resource_id format. Expected 'community_id:user_id'")
|
|
42
|
+
except Exception as e:
|
|
43
|
+
return self._handle_service_exception(e, 'get_by_id', resource_id=resource_id)
|
|
44
|
+
|
|
45
|
+
def update(self, resource_id: str, tenant_id: str, user_id: str, updates: Dict[str, Any]) -> ServiceResult[CommunityMember]:
|
|
46
|
+
"""Update method - updates member record."""
|
|
47
|
+
try:
|
|
48
|
+
if ':' in resource_id:
|
|
49
|
+
community_id, member_user_id = resource_id.split(':', 1)
|
|
50
|
+
membership = self.get_membership(community_id, member_user_id)
|
|
51
|
+
|
|
52
|
+
if not membership.success or not membership.data:
|
|
53
|
+
return ServiceResult.error_result("Membership not found")
|
|
54
|
+
|
|
55
|
+
# Apply updates
|
|
56
|
+
member = membership.data
|
|
57
|
+
if 'status' in updates:
|
|
58
|
+
member.status = updates['status']
|
|
59
|
+
if 'dues_status' in updates:
|
|
60
|
+
member.dues_status = updates['dues_status']
|
|
61
|
+
if 'last_payment_at' in updates:
|
|
62
|
+
member.last_payment_at = updates['last_payment_at']
|
|
63
|
+
if 'next_payment_due' in updates:
|
|
64
|
+
member.next_payment_due = updates['next_payment_due']
|
|
65
|
+
|
|
66
|
+
member.prep_for_save()
|
|
67
|
+
return self._save_model(member)
|
|
68
|
+
|
|
69
|
+
return ServiceResult.error_result("Invalid resource_id format. Expected 'community_id:user_id'")
|
|
70
|
+
except Exception as e:
|
|
71
|
+
return self._handle_service_exception(e, 'update', resource_id=resource_id)
|
|
72
|
+
|
|
73
|
+
def delete(self, resource_id: str, tenant_id: str, user_id: str) -> ServiceResult[bool]:
|
|
74
|
+
"""Delete method - soft deletes member."""
|
|
75
|
+
try:
|
|
76
|
+
if ':' in resource_id:
|
|
77
|
+
community_id, member_user_id = resource_id.split(':', 1)
|
|
78
|
+
return self.remove_member(community_id, member_user_id)
|
|
79
|
+
return ServiceResult.error_result("Invalid resource_id format. Expected 'community_id:user_id'")
|
|
80
|
+
except Exception as e:
|
|
81
|
+
return self._handle_service_exception(e, 'delete', resource_id=resource_id)
|
|
82
|
+
|
|
83
|
+
def add_member(self, community_id: str, user_id: str, invited_by_id: str = None,
|
|
84
|
+
status: str = "active") -> ServiceResult[CommunityMember]:
|
|
85
|
+
"""
|
|
86
|
+
Add a member to a community.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
community_id: Community ID
|
|
90
|
+
user_id: User ID to add
|
|
91
|
+
invited_by_id: Optional ID of user who invited
|
|
92
|
+
status: Member status (active, pending, etc.)
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
# Check if already a member
|
|
96
|
+
existing = self.get_membership(community_id, user_id)
|
|
97
|
+
if existing.success and existing.data:
|
|
98
|
+
if existing.data.status == "active":
|
|
99
|
+
return ServiceResult.success_result(existing.data)
|
|
100
|
+
# Reactivate if previously left or banned
|
|
101
|
+
existing.data.status = status
|
|
102
|
+
existing.data.joined_at = dt.datetime.now(dt.UTC).timestamp()
|
|
103
|
+
existing.data.prep_for_save()
|
|
104
|
+
return self._save_model(existing.data)
|
|
105
|
+
|
|
106
|
+
# Create new membership
|
|
107
|
+
member = CommunityMember()
|
|
108
|
+
member.community_id = community_id
|
|
109
|
+
member.user_id = user_id
|
|
110
|
+
member.status = status
|
|
111
|
+
member.invited_by_id = invited_by_id
|
|
112
|
+
member.joined_at = dt.datetime.now(dt.UTC).timestamp()
|
|
113
|
+
member.tenant_id = community_id # For tenant isolation if needed
|
|
114
|
+
member.prep_for_save()
|
|
115
|
+
|
|
116
|
+
return self._save_model(member)
|
|
117
|
+
|
|
118
|
+
except Exception as e:
|
|
119
|
+
return self._handle_service_exception(
|
|
120
|
+
e, 'add_member',
|
|
121
|
+
community_id=community_id,
|
|
122
|
+
user_id=user_id
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def remove_member(self, community_id: str, user_id: str) -> ServiceResult[bool]:
|
|
126
|
+
"""
|
|
127
|
+
Remove a member from a community (soft delete by status change).
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
community_id: Community ID
|
|
131
|
+
user_id: User ID to remove
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
membership = self.get_membership(community_id, user_id)
|
|
135
|
+
|
|
136
|
+
if not membership.success or not membership.data:
|
|
137
|
+
return ServiceResult.success_result(True) # Already not a member
|
|
138
|
+
|
|
139
|
+
# Mark as left instead of hard delete
|
|
140
|
+
membership.data.status = "left"
|
|
141
|
+
membership.data.prep_for_save()
|
|
142
|
+
|
|
143
|
+
return self._save_model(membership.data).map(lambda _: True)
|
|
144
|
+
|
|
145
|
+
except Exception as e:
|
|
146
|
+
return self._handle_service_exception(
|
|
147
|
+
e, 'remove_member',
|
|
148
|
+
community_id=community_id,
|
|
149
|
+
user_id=user_id
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def get_membership(self, community_id: str, user_id: str) -> ServiceResult[CommunityMember]:
|
|
153
|
+
"""
|
|
154
|
+
Get a specific membership record.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
community_id: Community ID
|
|
158
|
+
user_id: User ID
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
# Create temp object to build the primary key
|
|
162
|
+
temp = CommunityMember()
|
|
163
|
+
temp.community_id = community_id
|
|
164
|
+
temp.user_id = user_id
|
|
165
|
+
|
|
166
|
+
# Get by primary key (community + user)
|
|
167
|
+
result = self._get_by_primary_key(temp)
|
|
168
|
+
|
|
169
|
+
if not result.success or not result.data:
|
|
170
|
+
raise NotFoundError("Membership not found")
|
|
171
|
+
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
except Exception as e:
|
|
175
|
+
return self._handle_service_exception(
|
|
176
|
+
e, 'get_membership',
|
|
177
|
+
community_id=community_id,
|
|
178
|
+
user_id=user_id
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def is_member(self, community_id: str, user_id: str, active_only: bool = True) -> bool:
|
|
182
|
+
"""
|
|
183
|
+
Check if user is a member of community.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
community_id: Community ID
|
|
187
|
+
user_id: User ID
|
|
188
|
+
active_only: Only count active members (default True)
|
|
189
|
+
"""
|
|
190
|
+
try:
|
|
191
|
+
result = self.get_membership(community_id, user_id)
|
|
192
|
+
|
|
193
|
+
if not result.success or not result.data:
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
if active_only:
|
|
197
|
+
return result.data.is_active()
|
|
198
|
+
|
|
199
|
+
return result.data.status in ["active", "pending"]
|
|
200
|
+
|
|
201
|
+
except:
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
def list_members(self, community_id: str, status: str = None,
|
|
205
|
+
limit: int = 50) -> ServiceResult[List[CommunityMember]]:
|
|
206
|
+
"""
|
|
207
|
+
List members of a community.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
community_id: Community ID
|
|
211
|
+
status: Optional status filter (active, pending, etc.)
|
|
212
|
+
limit: Max results
|
|
213
|
+
"""
|
|
214
|
+
try:
|
|
215
|
+
temp = CommunityMember()
|
|
216
|
+
temp.community_id = community_id
|
|
217
|
+
|
|
218
|
+
if status:
|
|
219
|
+
# Query by status using GSI2
|
|
220
|
+
temp.status = status
|
|
221
|
+
result = self._query_by_index(temp, "gsi2", limit=limit, ascending=True)
|
|
222
|
+
else:
|
|
223
|
+
# Query all members using primary key begins_with
|
|
224
|
+
result = self._query_by_index(temp, "primary", limit=limit, ascending=True)
|
|
225
|
+
|
|
226
|
+
if not result.success:
|
|
227
|
+
return result
|
|
228
|
+
|
|
229
|
+
# Filter out deleted/left members if no status specified
|
|
230
|
+
if not status:
|
|
231
|
+
active_members = [m for m in result.data if m.status in ["active", "pending"]]
|
|
232
|
+
return ServiceResult.success_result(active_members)
|
|
233
|
+
|
|
234
|
+
return result
|
|
235
|
+
|
|
236
|
+
except Exception as e:
|
|
237
|
+
return self._handle_service_exception(
|
|
238
|
+
e, 'list_members',
|
|
239
|
+
community_id=community_id
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def list_user_communities(self, user_id: str, status: str = "active",
|
|
243
|
+
limit: int = 50) -> ServiceResult[List[CommunityMember]]:
|
|
244
|
+
"""
|
|
245
|
+
List communities a user is a member of.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
user_id: User ID
|
|
249
|
+
status: Member status filter (default: active)
|
|
250
|
+
limit: Max results
|
|
251
|
+
"""
|
|
252
|
+
try:
|
|
253
|
+
temp = CommunityMember()
|
|
254
|
+
temp.user_id = user_id
|
|
255
|
+
|
|
256
|
+
# Query by user using GSI1
|
|
257
|
+
result = self._query_by_index(
|
|
258
|
+
temp,
|
|
259
|
+
"gsi1",
|
|
260
|
+
limit=limit,
|
|
261
|
+
ascending=False # Most recently joined first
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if not result.success:
|
|
265
|
+
return result
|
|
266
|
+
|
|
267
|
+
# Filter by status if specified
|
|
268
|
+
if status:
|
|
269
|
+
filtered = [m for m in result.data if m.status == status]
|
|
270
|
+
return ServiceResult.success_result(filtered)
|
|
271
|
+
|
|
272
|
+
return result
|
|
273
|
+
|
|
274
|
+
except Exception as e:
|
|
275
|
+
return self._handle_service_exception(
|
|
276
|
+
e, 'list_user_communities',
|
|
277
|
+
user_id=user_id
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def get_member_count(self, community_id: str, status: str = "active") -> ServiceResult[int]:
|
|
281
|
+
"""
|
|
282
|
+
Get count of members in a community.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
community_id: Community ID
|
|
286
|
+
status: Status filter (default: active)
|
|
287
|
+
"""
|
|
288
|
+
try:
|
|
289
|
+
members_result = self.list_members(community_id, status=status, limit=1000)
|
|
290
|
+
|
|
291
|
+
if not members_result.success:
|
|
292
|
+
return ServiceResult.error_result("Failed to count members")
|
|
293
|
+
|
|
294
|
+
count = len(members_result.data)
|
|
295
|
+
return ServiceResult.success_result(count)
|
|
296
|
+
|
|
297
|
+
except Exception as e:
|
|
298
|
+
return self._handle_service_exception(
|
|
299
|
+
e, 'get_member_count',
|
|
300
|
+
community_id=community_id
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def update_member_status(self, community_id: str, user_id: str,
|
|
304
|
+
status: str) -> ServiceResult[CommunityMember]:
|
|
305
|
+
"""
|
|
306
|
+
Update a member's status.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
community_id: Community ID
|
|
310
|
+
user_id: User ID
|
|
311
|
+
status: New status (active, pending, banned, left)
|
|
312
|
+
"""
|
|
313
|
+
try:
|
|
314
|
+
membership = self.get_membership(community_id, user_id)
|
|
315
|
+
|
|
316
|
+
if not membership.success or not membership.data:
|
|
317
|
+
raise NotFoundError("Membership not found")
|
|
318
|
+
|
|
319
|
+
membership.data.status = status
|
|
320
|
+
membership.data.prep_for_save()
|
|
321
|
+
|
|
322
|
+
return self._save_model(membership.data)
|
|
323
|
+
|
|
324
|
+
except Exception as e:
|
|
325
|
+
return self._handle_service_exception(
|
|
326
|
+
e, 'update_member_status',
|
|
327
|
+
community_id=community_id,
|
|
328
|
+
user_id=user_id
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
def approve_pending_member(self, community_id: str, user_id: str) -> ServiceResult[CommunityMember]:
|
|
332
|
+
"""
|
|
333
|
+
Approve a pending member.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
community_id: Community ID
|
|
337
|
+
user_id: User ID to approve
|
|
338
|
+
"""
|
|
339
|
+
return self.update_member_status(community_id, user_id, "active")
|
|
340
|
+
|
|
341
|
+
def ban_member(self, community_id: str, user_id: str) -> ServiceResult[CommunityMember]:
|
|
342
|
+
"""
|
|
343
|
+
Ban a member from the community.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
community_id: Community ID
|
|
347
|
+
user_id: User ID to ban
|
|
348
|
+
"""
|
|
349
|
+
return self.update_member_status(community_id, user_id, "banned")
|
|
350
|
+
|
|
351
|
+
def update_dues_status(self, community_id: str, user_id: str,
|
|
352
|
+
dues_status: str, next_payment_due: float = None) -> ServiceResult[CommunityMember]:
|
|
353
|
+
"""
|
|
354
|
+
Update member's dues status.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
community_id: Community ID
|
|
358
|
+
user_id: User ID
|
|
359
|
+
dues_status: New dues status (current, overdue, exempt, pending)
|
|
360
|
+
next_payment_due: Optional next payment due date (timestamp)
|
|
361
|
+
"""
|
|
362
|
+
try:
|
|
363
|
+
membership = self.get_membership(community_id, user_id)
|
|
364
|
+
|
|
365
|
+
if not membership.success or not membership.data:
|
|
366
|
+
raise NotFoundError("Membership not found")
|
|
367
|
+
|
|
368
|
+
membership.data.dues_status = dues_status
|
|
369
|
+
if dues_status == "current":
|
|
370
|
+
membership.data.last_payment_at = dt.datetime.now(dt.UTC).timestamp()
|
|
371
|
+
if next_payment_due:
|
|
372
|
+
membership.data.next_payment_due = next_payment_due
|
|
373
|
+
|
|
374
|
+
membership.data.prep_for_save()
|
|
375
|
+
|
|
376
|
+
return self._save_model(membership.data)
|
|
377
|
+
|
|
378
|
+
except Exception as e:
|
|
379
|
+
return self._handle_service_exception(
|
|
380
|
+
e, 'update_dues_status',
|
|
381
|
+
community_id=community_id,
|
|
382
|
+
user_id=user_id
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
def list_members_with_overdue_dues(self, community_id: str,
|
|
386
|
+
limit: int = 50) -> ServiceResult[List[CommunityMember]]:
|
|
387
|
+
"""
|
|
388
|
+
List members with overdue dues using GSI3.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
community_id: Community ID
|
|
392
|
+
limit: Max results
|
|
393
|
+
"""
|
|
394
|
+
try:
|
|
395
|
+
temp = CommunityMember()
|
|
396
|
+
temp.community_id = community_id
|
|
397
|
+
temp.dues_status = "overdue"
|
|
398
|
+
|
|
399
|
+
result = self._query_by_index(
|
|
400
|
+
temp,
|
|
401
|
+
"gsi3",
|
|
402
|
+
limit=limit,
|
|
403
|
+
ascending=True # Oldest overdue first
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
return result
|
|
407
|
+
|
|
408
|
+
except Exception as e:
|
|
409
|
+
return self._handle_service_exception(
|
|
410
|
+
e, 'list_members_with_overdue_dues',
|
|
411
|
+
community_id=community_id
|
|
412
|
+
)
|