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,440 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Geek Cafe, LLC
|
|
3
|
+
MIT License. See Project Root for the license information.
|
|
4
|
+
|
|
5
|
+
Subscription model for tenant billing and plan management.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from boto3_assist.dynamodb.dynamodb_index import DynamoDBIndex, DynamoDBKey
|
|
10
|
+
from typing import Dict, Any, Optional
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from geek_cafe_saas_sdk.models.base_model import BaseModel
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Subscription(BaseModel):
|
|
16
|
+
"""
|
|
17
|
+
Subscription/billing model for tenant plans.
|
|
18
|
+
|
|
19
|
+
Tracks subscription history with one active subscription per tenant.
|
|
20
|
+
Uses date-sorted SK in GSI1 to maintain sortable history.
|
|
21
|
+
|
|
22
|
+
Key Features:
|
|
23
|
+
- Full billing history (all subscriptions for a tenant)
|
|
24
|
+
- Active subscription pointer (separate item for O(1) access)
|
|
25
|
+
- Trial period tracking
|
|
26
|
+
- Billing period management
|
|
27
|
+
- Payment status tracking
|
|
28
|
+
|
|
29
|
+
Access Patterns:
|
|
30
|
+
- Get subscription by ID (primary key)
|
|
31
|
+
- List subscription history for tenant (GSI1, date-sorted)
|
|
32
|
+
- Query subscriptions by status (GSI2, for billing jobs)
|
|
33
|
+
- Get active subscription via pointer item
|
|
34
|
+
|
|
35
|
+
Active Subscription Pointer:
|
|
36
|
+
- Separate DynamoDB item with SK="subscription#active"
|
|
37
|
+
- Contains active_subscription_id for O(1) lookups
|
|
38
|
+
- Updated atomically with TransactWrite when subscription changes
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self):
|
|
42
|
+
super().__init__()
|
|
43
|
+
# tenant_id inherited from BaseModel
|
|
44
|
+
|
|
45
|
+
# Subscription status
|
|
46
|
+
self._status: str = "trial" # trial|active|past_due|canceled|expired
|
|
47
|
+
|
|
48
|
+
# Plan details
|
|
49
|
+
self._plan_code: str | None = None # "free"|"basic"|"pro"|"enterprise"
|
|
50
|
+
self._plan_name: str | None = None # Display name
|
|
51
|
+
self._seat_count: int = 1 # Number of users/seats
|
|
52
|
+
|
|
53
|
+
# Pricing
|
|
54
|
+
self._price_cents: int = 0 # Price in cents (e.g., 2999 = $29.99)
|
|
55
|
+
self._currency: str = "USD"
|
|
56
|
+
self._billing_interval: str = "month" # month|year
|
|
57
|
+
|
|
58
|
+
# Trial period
|
|
59
|
+
self._trial_ends_utc_ts: float | None = None
|
|
60
|
+
self._is_trial: bool = False
|
|
61
|
+
|
|
62
|
+
# Billing periods
|
|
63
|
+
self._current_period_start_utc_ts: float | None = None
|
|
64
|
+
self._current_period_end_utc_ts: float | None = None
|
|
65
|
+
|
|
66
|
+
# Cancellation
|
|
67
|
+
self._canceled_utc_ts: float | None = None
|
|
68
|
+
self._cancel_at_period_end: bool = False
|
|
69
|
+
self._cancellation_reason: str | None = None
|
|
70
|
+
|
|
71
|
+
# Payment tracking
|
|
72
|
+
self._last_payment_utc_ts: float | None = None
|
|
73
|
+
self._last_payment_amount_cents: int | None = None
|
|
74
|
+
self._next_billing_utc_ts: float | None = None
|
|
75
|
+
self._payment_method: str | None = None # "card"|"invoice"|"paypal", etc.
|
|
76
|
+
|
|
77
|
+
# External billing integration
|
|
78
|
+
self._external_subscription_id: str | None = None # Stripe, Paddle, etc.
|
|
79
|
+
self._external_customer_id: str | None = None
|
|
80
|
+
|
|
81
|
+
# Metadata
|
|
82
|
+
self._notes: str | None = None
|
|
83
|
+
|
|
84
|
+
self._setup_indexes()
|
|
85
|
+
|
|
86
|
+
def _setup_indexes(self):
|
|
87
|
+
"""Setup DynamoDB indexes for efficient subscription queries."""
|
|
88
|
+
|
|
89
|
+
# Primary index: subscription by ID
|
|
90
|
+
primary: DynamoDBIndex = DynamoDBIndex()
|
|
91
|
+
primary.name = "primary"
|
|
92
|
+
primary.partition_key.attribute_name = "pk"
|
|
93
|
+
primary.partition_key.value = lambda: DynamoDBKey.build_key(
|
|
94
|
+
("subscription", self.id)
|
|
95
|
+
)
|
|
96
|
+
primary.sort_key.attribute_name = "sk"
|
|
97
|
+
primary.sort_key.value = lambda: DynamoDBKey.build_key(
|
|
98
|
+
("subscription", self.id)
|
|
99
|
+
)
|
|
100
|
+
self.indexes.add_primary(primary)
|
|
101
|
+
|
|
102
|
+
# GSI1: Subscriptions by tenant (history sorted by period start date)
|
|
103
|
+
# SK includes date prefix for sortable history
|
|
104
|
+
gsi: DynamoDBIndex = DynamoDBIndex()
|
|
105
|
+
gsi.name = "gsi1"
|
|
106
|
+
gsi.partition_key.attribute_name = f"{gsi.name}_pk"
|
|
107
|
+
gsi.partition_key.value = lambda: DynamoDBKey.build_key(
|
|
108
|
+
("tenant", self.tenant_id)
|
|
109
|
+
)
|
|
110
|
+
gsi.sort_key.attribute_name = f"{gsi.name}_sk"
|
|
111
|
+
gsi.sort_key.value = lambda: DynamoDBKey.build_key(
|
|
112
|
+
("subscription", self._get_date_prefix()),
|
|
113
|
+
("id", self.id)
|
|
114
|
+
)
|
|
115
|
+
self.indexes.add_secondary(gsi)
|
|
116
|
+
|
|
117
|
+
# GSI2: Subscriptions by status (for billing queries/jobs)
|
|
118
|
+
# Sorted by next_billing_date for processing queues
|
|
119
|
+
gsi: DynamoDBIndex = DynamoDBIndex()
|
|
120
|
+
gsi.name = "gsi2"
|
|
121
|
+
gsi.partition_key.attribute_name = f"{gsi.name}_pk"
|
|
122
|
+
gsi.partition_key.value = lambda: DynamoDBKey.build_key(
|
|
123
|
+
("subscription_status", self.status)
|
|
124
|
+
)
|
|
125
|
+
gsi.sort_key.attribute_name = f"{gsi.name}_sk"
|
|
126
|
+
gsi.sort_key.value = lambda: DynamoDBKey.build_key(
|
|
127
|
+
("next_billing", self.next_billing_utc_ts or 0)
|
|
128
|
+
)
|
|
129
|
+
self.indexes.add_secondary(gsi)
|
|
130
|
+
|
|
131
|
+
# GSI3: Subscriptions by plan code (for analytics)
|
|
132
|
+
gsi: DynamoDBIndex = DynamoDBIndex()
|
|
133
|
+
gsi.name = "gsi3"
|
|
134
|
+
gsi.partition_key.attribute_name = f"{gsi.name}_pk"
|
|
135
|
+
gsi.partition_key.value = lambda: DynamoDBKey.build_key(
|
|
136
|
+
("plan_code", self.plan_code or "unknown")
|
|
137
|
+
)
|
|
138
|
+
gsi.sort_key.attribute_name = f"{gsi.name}_sk"
|
|
139
|
+
gsi.sort_key.value = lambda: DynamoDBKey.build_key(
|
|
140
|
+
("ts", self.created_utc_ts)
|
|
141
|
+
)
|
|
142
|
+
self.indexes.add_secondary(gsi)
|
|
143
|
+
|
|
144
|
+
def _get_date_prefix(self) -> str | None:
|
|
145
|
+
"""
|
|
146
|
+
Get date prefix for sortable history (yyyyMMdd).
|
|
147
|
+
|
|
148
|
+
Uses current_period_start_utc_ts if available, otherwise created_utc_ts.
|
|
149
|
+
"""
|
|
150
|
+
timestamp = self.current_period_start_utc_ts or self.created_utc_ts
|
|
151
|
+
if timestamp is None or timestamp == 0:
|
|
152
|
+
return None
|
|
153
|
+
dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
|
154
|
+
return dt.strftime("%Y%m%d")
|
|
155
|
+
|
|
156
|
+
# Status
|
|
157
|
+
@property
|
|
158
|
+
def status(self) -> str:
|
|
159
|
+
"""Subscription status."""
|
|
160
|
+
return self._status
|
|
161
|
+
|
|
162
|
+
@status.setter
|
|
163
|
+
def status(self, value: str):
|
|
164
|
+
valid_statuses = ["trial", "active", "past_due", "canceled", "expired"]
|
|
165
|
+
if value not in valid_statuses:
|
|
166
|
+
raise ValueError(f"Invalid status: {value}. Must be one of {valid_statuses}")
|
|
167
|
+
self._status = value
|
|
168
|
+
|
|
169
|
+
# Plan Code
|
|
170
|
+
@property
|
|
171
|
+
def plan_code(self) -> str | None:
|
|
172
|
+
"""Plan identifier code."""
|
|
173
|
+
return self._plan_code
|
|
174
|
+
|
|
175
|
+
@plan_code.setter
|
|
176
|
+
def plan_code(self, value: str | None):
|
|
177
|
+
self._plan_code = value
|
|
178
|
+
|
|
179
|
+
# Plan Name
|
|
180
|
+
@property
|
|
181
|
+
def plan_name(self) -> str | None:
|
|
182
|
+
"""Plan display name."""
|
|
183
|
+
return self._plan_name
|
|
184
|
+
|
|
185
|
+
@plan_name.setter
|
|
186
|
+
def plan_name(self, value: str | None):
|
|
187
|
+
self._plan_name = value
|
|
188
|
+
|
|
189
|
+
# Seat Count
|
|
190
|
+
@property
|
|
191
|
+
def seat_count(self) -> int:
|
|
192
|
+
"""Number of seats/users."""
|
|
193
|
+
return self._seat_count
|
|
194
|
+
|
|
195
|
+
@seat_count.setter
|
|
196
|
+
def seat_count(self, value: int):
|
|
197
|
+
if value < 1:
|
|
198
|
+
raise ValueError("seat_count must be at least 1")
|
|
199
|
+
self._seat_count = value
|
|
200
|
+
|
|
201
|
+
# Price Cents
|
|
202
|
+
@property
|
|
203
|
+
def price_cents(self) -> int:
|
|
204
|
+
"""Price in cents."""
|
|
205
|
+
return self._price_cents
|
|
206
|
+
|
|
207
|
+
@price_cents.setter
|
|
208
|
+
def price_cents(self, value: int):
|
|
209
|
+
if value < 0:
|
|
210
|
+
raise ValueError("price_cents cannot be negative")
|
|
211
|
+
self._price_cents = value
|
|
212
|
+
|
|
213
|
+
# Currency
|
|
214
|
+
@property
|
|
215
|
+
def currency(self) -> str:
|
|
216
|
+
"""Currency code (e.g., USD, EUR)."""
|
|
217
|
+
return self._currency
|
|
218
|
+
|
|
219
|
+
@currency.setter
|
|
220
|
+
def currency(self, value: str):
|
|
221
|
+
self._currency = value.upper() if value else "USD"
|
|
222
|
+
|
|
223
|
+
# Billing Interval
|
|
224
|
+
@property
|
|
225
|
+
def billing_interval(self) -> str:
|
|
226
|
+
"""Billing interval (month|year)."""
|
|
227
|
+
return self._billing_interval
|
|
228
|
+
|
|
229
|
+
@billing_interval.setter
|
|
230
|
+
def billing_interval(self, value: str):
|
|
231
|
+
if value not in ["month", "year"]:
|
|
232
|
+
raise ValueError("billing_interval must be 'month' or 'year'")
|
|
233
|
+
self._billing_interval = value
|
|
234
|
+
|
|
235
|
+
# Trial Ends
|
|
236
|
+
@property
|
|
237
|
+
def trial_ends_utc_ts(self) -> float | None:
|
|
238
|
+
"""Trial period end timestamp."""
|
|
239
|
+
return self._trial_ends_utc_ts
|
|
240
|
+
|
|
241
|
+
@trial_ends_utc_ts.setter
|
|
242
|
+
def trial_ends_utc_ts(self, value: float | None):
|
|
243
|
+
self._trial_ends_utc_ts = value
|
|
244
|
+
|
|
245
|
+
# Is Trial
|
|
246
|
+
@property
|
|
247
|
+
def is_trial(self) -> bool:
|
|
248
|
+
"""Whether subscription is in trial period."""
|
|
249
|
+
return self._is_trial
|
|
250
|
+
|
|
251
|
+
@is_trial.setter
|
|
252
|
+
def is_trial(self, value: bool):
|
|
253
|
+
self._is_trial = value
|
|
254
|
+
|
|
255
|
+
# Current Period Start
|
|
256
|
+
@property
|
|
257
|
+
def current_period_start_utc_ts(self) -> float | None:
|
|
258
|
+
"""Current billing period start timestamp."""
|
|
259
|
+
return self._current_period_start_utc_ts
|
|
260
|
+
|
|
261
|
+
@current_period_start_utc_ts.setter
|
|
262
|
+
def current_period_start_utc_ts(self, value: float | None):
|
|
263
|
+
self._current_period_start_utc_ts = value
|
|
264
|
+
|
|
265
|
+
# Current Period End
|
|
266
|
+
@property
|
|
267
|
+
def current_period_end_utc_ts(self) -> float | None:
|
|
268
|
+
"""Current billing period end timestamp."""
|
|
269
|
+
return self._current_period_end_utc_ts
|
|
270
|
+
|
|
271
|
+
@current_period_end_utc_ts.setter
|
|
272
|
+
def current_period_end_utc_ts(self, value: float | None):
|
|
273
|
+
self._current_period_end_utc_ts = value
|
|
274
|
+
|
|
275
|
+
# Canceled
|
|
276
|
+
@property
|
|
277
|
+
def canceled_utc_ts(self) -> float | None:
|
|
278
|
+
"""Cancellation timestamp."""
|
|
279
|
+
return self._canceled_utc_ts
|
|
280
|
+
|
|
281
|
+
@canceled_utc_ts.setter
|
|
282
|
+
def canceled_utc_ts(self, value: float | None):
|
|
283
|
+
self._canceled_utc_ts = value
|
|
284
|
+
|
|
285
|
+
# Cancel at Period End
|
|
286
|
+
@property
|
|
287
|
+
def cancel_at_period_end(self) -> bool:
|
|
288
|
+
"""Whether to cancel at end of current period."""
|
|
289
|
+
return self._cancel_at_period_end
|
|
290
|
+
|
|
291
|
+
@cancel_at_period_end.setter
|
|
292
|
+
def cancel_at_period_end(self, value: bool):
|
|
293
|
+
self._cancel_at_period_end = value
|
|
294
|
+
|
|
295
|
+
# Cancellation Reason
|
|
296
|
+
@property
|
|
297
|
+
def cancellation_reason(self) -> str | None:
|
|
298
|
+
"""Reason for cancellation."""
|
|
299
|
+
return self._cancellation_reason
|
|
300
|
+
|
|
301
|
+
@cancellation_reason.setter
|
|
302
|
+
def cancellation_reason(self, value: str | None):
|
|
303
|
+
self._cancellation_reason = value
|
|
304
|
+
|
|
305
|
+
# Last Payment
|
|
306
|
+
@property
|
|
307
|
+
def last_payment_utc_ts(self) -> float | None:
|
|
308
|
+
"""Last successful payment timestamp."""
|
|
309
|
+
return self._last_payment_utc_ts
|
|
310
|
+
|
|
311
|
+
@last_payment_utc_ts.setter
|
|
312
|
+
def last_payment_utc_ts(self, value: float | None):
|
|
313
|
+
self._last_payment_utc_ts = value
|
|
314
|
+
|
|
315
|
+
# Last Payment Amount
|
|
316
|
+
@property
|
|
317
|
+
def last_payment_amount_cents(self) -> int | None:
|
|
318
|
+
"""Last payment amount in cents."""
|
|
319
|
+
return self._last_payment_amount_cents
|
|
320
|
+
|
|
321
|
+
@last_payment_amount_cents.setter
|
|
322
|
+
def last_payment_amount_cents(self, value: int | None):
|
|
323
|
+
self._last_payment_amount_cents = value
|
|
324
|
+
|
|
325
|
+
# Next Billing
|
|
326
|
+
@property
|
|
327
|
+
def next_billing_utc_ts(self) -> float | None:
|
|
328
|
+
"""Next billing date timestamp."""
|
|
329
|
+
return self._next_billing_utc_ts
|
|
330
|
+
|
|
331
|
+
@next_billing_utc_ts.setter
|
|
332
|
+
def next_billing_utc_ts(self, value: float | None):
|
|
333
|
+
self._next_billing_utc_ts = value
|
|
334
|
+
|
|
335
|
+
# Payment Method
|
|
336
|
+
@property
|
|
337
|
+
def payment_method(self) -> str | None:
|
|
338
|
+
"""Payment method type."""
|
|
339
|
+
return self._payment_method
|
|
340
|
+
|
|
341
|
+
@payment_method.setter
|
|
342
|
+
def payment_method(self, value: str | None):
|
|
343
|
+
self._payment_method = value
|
|
344
|
+
|
|
345
|
+
# External Subscription ID
|
|
346
|
+
@property
|
|
347
|
+
def external_subscription_id(self) -> str | None:
|
|
348
|
+
"""External billing provider subscription ID."""
|
|
349
|
+
return self._external_subscription_id
|
|
350
|
+
|
|
351
|
+
@external_subscription_id.setter
|
|
352
|
+
def external_subscription_id(self, value: str | None):
|
|
353
|
+
self._external_subscription_id = value
|
|
354
|
+
|
|
355
|
+
# External Customer ID
|
|
356
|
+
@property
|
|
357
|
+
def external_customer_id(self) -> str | None:
|
|
358
|
+
"""External billing provider customer ID."""
|
|
359
|
+
return self._external_customer_id
|
|
360
|
+
|
|
361
|
+
@external_customer_id.setter
|
|
362
|
+
def external_customer_id(self, value: str | None):
|
|
363
|
+
self._external_customer_id = value
|
|
364
|
+
|
|
365
|
+
# Notes
|
|
366
|
+
@property
|
|
367
|
+
def notes(self) -> str | None:
|
|
368
|
+
"""Internal notes about subscription."""
|
|
369
|
+
return self._notes
|
|
370
|
+
|
|
371
|
+
@notes.setter
|
|
372
|
+
def notes(self, value: str | None):
|
|
373
|
+
self._notes = value
|
|
374
|
+
|
|
375
|
+
# Helper Methods
|
|
376
|
+
|
|
377
|
+
def is_active(self) -> bool:
|
|
378
|
+
"""Check if subscription is active."""
|
|
379
|
+
return self._status == "active"
|
|
380
|
+
|
|
381
|
+
def is_trial_active(self) -> bool:
|
|
382
|
+
"""Check if subscription is in active trial."""
|
|
383
|
+
return self._status == "trial"
|
|
384
|
+
|
|
385
|
+
def is_canceled(self) -> bool:
|
|
386
|
+
"""Check if subscription is canceled."""
|
|
387
|
+
return self._status == "canceled"
|
|
388
|
+
|
|
389
|
+
def is_past_due(self) -> bool:
|
|
390
|
+
"""Check if subscription payment is past due."""
|
|
391
|
+
return self._status == "past_due"
|
|
392
|
+
|
|
393
|
+
def is_expired(self) -> bool:
|
|
394
|
+
"""Check if subscription is expired."""
|
|
395
|
+
return self._status == "expired"
|
|
396
|
+
|
|
397
|
+
def cancel(self, reason: str | None = None, immediate: bool = False):
|
|
398
|
+
"""
|
|
399
|
+
Cancel subscription.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
reason: Optional cancellation reason
|
|
403
|
+
immediate: If True, cancel immediately; if False, cancel at period end
|
|
404
|
+
"""
|
|
405
|
+
import datetime as dt
|
|
406
|
+
|
|
407
|
+
self._status = "canceled"
|
|
408
|
+
self._canceled_utc_ts = dt.datetime.now(dt.UTC).timestamp()
|
|
409
|
+
self._cancellation_reason = reason
|
|
410
|
+
|
|
411
|
+
if not immediate:
|
|
412
|
+
self._cancel_at_period_end = True
|
|
413
|
+
|
|
414
|
+
def reactivate(self):
|
|
415
|
+
"""Reactivate a canceled subscription."""
|
|
416
|
+
if self._status == "canceled":
|
|
417
|
+
self._status = "active"
|
|
418
|
+
self._canceled_utc_ts = None
|
|
419
|
+
self._cancel_at_period_end = False
|
|
420
|
+
self._cancellation_reason = None
|
|
421
|
+
|
|
422
|
+
def record_payment(self, amount_cents: int):
|
|
423
|
+
"""Record a successful payment."""
|
|
424
|
+
import datetime as dt
|
|
425
|
+
|
|
426
|
+
self._last_payment_utc_ts = dt.datetime.now(dt.UTC).timestamp()
|
|
427
|
+
self._last_payment_amount_cents = amount_cents
|
|
428
|
+
|
|
429
|
+
# Update status if was past_due
|
|
430
|
+
if self._status == "past_due":
|
|
431
|
+
self._status = "active"
|
|
432
|
+
|
|
433
|
+
def mark_past_due(self):
|
|
434
|
+
"""Mark subscription as past due (payment failed)."""
|
|
435
|
+
self._status = "past_due"
|
|
436
|
+
|
|
437
|
+
def get_price_display(self) -> str:
|
|
438
|
+
"""Get formatted price for display."""
|
|
439
|
+
dollars = self._price_cents / 100
|
|
440
|
+
return f"${dollars:.2f} {self._currency}"
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Geek Cafe, LLC
|
|
3
|
+
MIT License. See Project Root for the license information.
|
|
4
|
+
|
|
5
|
+
Tenant model for multi-tenant SaaS organizations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from boto3_assist.dynamodb.dynamodb_index import DynamoDBIndex, DynamoDBKey
|
|
9
|
+
from typing import Dict, Any, Optional
|
|
10
|
+
from geek_cafe_saas_sdk.models.base_model import BaseModel
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Tenant(BaseModel):
|
|
14
|
+
"""
|
|
15
|
+
Tenant/Organization model for multi-tenant SaaS.
|
|
16
|
+
|
|
17
|
+
Represents a customer organization with multiple users and a subscription.
|
|
18
|
+
Each tenant has a plan tier, status, and customizable feature flags.
|
|
19
|
+
|
|
20
|
+
Key Features:
|
|
21
|
+
- Multi-user support (one or many users per tenant)
|
|
22
|
+
- Subscription management (linked via tenant_id)
|
|
23
|
+
- Feature flags for plan differentiation
|
|
24
|
+
- Primary contact tracking
|
|
25
|
+
|
|
26
|
+
Access Patterns:
|
|
27
|
+
- Get tenant by ID (primary key)
|
|
28
|
+
- List tenants by status (GSI1)
|
|
29
|
+
- List all tenants sorted by name (GSI2)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self):
|
|
33
|
+
super().__init__()
|
|
34
|
+
|
|
35
|
+
# Basic info
|
|
36
|
+
self._name: str | None = None
|
|
37
|
+
self._status: str = "active" # active|inactive|archived
|
|
38
|
+
self._plan_tier: str = "free" # free|basic|pro|enterprise
|
|
39
|
+
|
|
40
|
+
# Relationships
|
|
41
|
+
self._primary_contact_user_id: str | None = None
|
|
42
|
+
|
|
43
|
+
# Limits & settings
|
|
44
|
+
self._max_users: int | None = None # Null = unlimited
|
|
45
|
+
self._features: Dict[str, Any] = {} # Feature flags per tenant
|
|
46
|
+
|
|
47
|
+
# Metadata
|
|
48
|
+
self._description: str | None = None
|
|
49
|
+
self._website: str | None = None
|
|
50
|
+
self._logo_url: str | None = None
|
|
51
|
+
|
|
52
|
+
self._setup_indexes()
|
|
53
|
+
|
|
54
|
+
def _setup_indexes(self):
|
|
55
|
+
"""Setup DynamoDB indexes for efficient tenant queries."""
|
|
56
|
+
|
|
57
|
+
# Primary index: tenant by ID
|
|
58
|
+
primary: DynamoDBIndex = DynamoDBIndex()
|
|
59
|
+
primary.name = "primary"
|
|
60
|
+
primary.partition_key.attribute_name = "pk"
|
|
61
|
+
primary.partition_key.value = lambda: DynamoDBKey.build_key(("tenant", self.id))
|
|
62
|
+
primary.sort_key.attribute_name = "sk"
|
|
63
|
+
primary.sort_key.value = lambda: DynamoDBKey.build_key(("tenant", self.id))
|
|
64
|
+
self.indexes.add_primary(primary)
|
|
65
|
+
|
|
66
|
+
# GSI1: Tenants by status (for admin queries - active, inactive, archived)
|
|
67
|
+
gsi: DynamoDBIndex = DynamoDBIndex()
|
|
68
|
+
gsi.name = "gsi1"
|
|
69
|
+
gsi.partition_key.attribute_name = f"{gsi.name}_pk"
|
|
70
|
+
gsi.partition_key.value = lambda: DynamoDBKey.build_key(
|
|
71
|
+
("tenant_status", self.status)
|
|
72
|
+
)
|
|
73
|
+
gsi.sort_key.attribute_name = f"{gsi.name}_sk"
|
|
74
|
+
gsi.sort_key.value = lambda: DynamoDBKey.build_key(
|
|
75
|
+
("ts", self.created_utc_ts)
|
|
76
|
+
)
|
|
77
|
+
self.indexes.add_secondary(gsi)
|
|
78
|
+
|
|
79
|
+
# GSI2: All tenants sorted by name (for admin listing)
|
|
80
|
+
gsi: DynamoDBIndex = DynamoDBIndex()
|
|
81
|
+
gsi.name = "gsi2"
|
|
82
|
+
gsi.partition_key.attribute_name = f"{gsi.name}_pk"
|
|
83
|
+
gsi.partition_key.value = lambda: DynamoDBKey.build_key(("tenant", "all"))
|
|
84
|
+
gsi.sort_key.attribute_name = f"{gsi.name}_sk"
|
|
85
|
+
gsi.sort_key.value = lambda: DynamoDBKey.build_key(
|
|
86
|
+
("name", self.name or ""),
|
|
87
|
+
("ts", self.created_utc_ts)
|
|
88
|
+
)
|
|
89
|
+
self.indexes.add_secondary(gsi)
|
|
90
|
+
|
|
91
|
+
# GSI3: Tenants by plan tier (for analytics/reporting)
|
|
92
|
+
gsi: DynamoDBIndex = DynamoDBIndex()
|
|
93
|
+
gsi.name = "gsi3"
|
|
94
|
+
gsi.partition_key.attribute_name = f"{gsi.name}_pk"
|
|
95
|
+
gsi.partition_key.value = lambda: DynamoDBKey.build_key(
|
|
96
|
+
("plan_tier", self.plan_tier)
|
|
97
|
+
)
|
|
98
|
+
gsi.sort_key.attribute_name = f"{gsi.name}_sk"
|
|
99
|
+
gsi.sort_key.value = lambda: DynamoDBKey.build_key(
|
|
100
|
+
("ts", self.created_utc_ts)
|
|
101
|
+
)
|
|
102
|
+
self.indexes.add_secondary(gsi)
|
|
103
|
+
|
|
104
|
+
# Name
|
|
105
|
+
@property
|
|
106
|
+
def name(self) -> str | None:
|
|
107
|
+
"""Tenant organization name."""
|
|
108
|
+
return self._name
|
|
109
|
+
|
|
110
|
+
@name.setter
|
|
111
|
+
def name(self, value: str | None):
|
|
112
|
+
self._name = value
|
|
113
|
+
|
|
114
|
+
# Status
|
|
115
|
+
@property
|
|
116
|
+
def status(self) -> str:
|
|
117
|
+
"""Tenant status (active|inactive|archived)."""
|
|
118
|
+
return self._status
|
|
119
|
+
|
|
120
|
+
@status.setter
|
|
121
|
+
def status(self, value: str):
|
|
122
|
+
if value not in ["active", "inactive", "archived"]:
|
|
123
|
+
raise ValueError(f"Invalid status: {value}. Must be active, inactive, or archived.")
|
|
124
|
+
self._status = value
|
|
125
|
+
|
|
126
|
+
# Plan Tier
|
|
127
|
+
@property
|
|
128
|
+
def plan_tier(self) -> str:
|
|
129
|
+
"""Subscription plan tier (free|basic|pro|enterprise)."""
|
|
130
|
+
return self._plan_tier
|
|
131
|
+
|
|
132
|
+
@plan_tier.setter
|
|
133
|
+
def plan_tier(self, value: str):
|
|
134
|
+
if value not in ["free", "basic", "pro", "enterprise"]:
|
|
135
|
+
raise ValueError(f"Invalid plan_tier: {value}. Must be free, basic, pro, or enterprise.")
|
|
136
|
+
self._plan_tier = value
|
|
137
|
+
|
|
138
|
+
# Primary Contact User ID
|
|
139
|
+
@property
|
|
140
|
+
def primary_contact_user_id(self) -> str | None:
|
|
141
|
+
"""User ID of primary contact/admin."""
|
|
142
|
+
return self._primary_contact_user_id
|
|
143
|
+
|
|
144
|
+
@primary_contact_user_id.setter
|
|
145
|
+
def primary_contact_user_id(self, value: str | None):
|
|
146
|
+
self._primary_contact_user_id = value
|
|
147
|
+
|
|
148
|
+
# Max Users
|
|
149
|
+
@property
|
|
150
|
+
def max_users(self) -> int | None:
|
|
151
|
+
"""Maximum users allowed (None = unlimited)."""
|
|
152
|
+
return self._max_users
|
|
153
|
+
|
|
154
|
+
@max_users.setter
|
|
155
|
+
def max_users(self, value: int | None):
|
|
156
|
+
if value is not None and value < 1:
|
|
157
|
+
raise ValueError("max_users must be at least 1 or None for unlimited")
|
|
158
|
+
self._max_users = value
|
|
159
|
+
|
|
160
|
+
# Features
|
|
161
|
+
@property
|
|
162
|
+
def features(self) -> Dict[str, Any]:
|
|
163
|
+
"""
|
|
164
|
+
Feature flags for tenant.
|
|
165
|
+
|
|
166
|
+
Example:
|
|
167
|
+
{
|
|
168
|
+
"chat": True,
|
|
169
|
+
"events": True,
|
|
170
|
+
"analytics": False,
|
|
171
|
+
"api_access": True,
|
|
172
|
+
"custom_branding": True
|
|
173
|
+
}
|
|
174
|
+
"""
|
|
175
|
+
return self._features
|
|
176
|
+
|
|
177
|
+
@features.setter
|
|
178
|
+
def features(self, value: Dict[str, Any]):
|
|
179
|
+
if value is None:
|
|
180
|
+
self._features = {}
|
|
181
|
+
elif isinstance(value, dict):
|
|
182
|
+
self._features = value
|
|
183
|
+
else:
|
|
184
|
+
self._features = {}
|
|
185
|
+
|
|
186
|
+
# Description
|
|
187
|
+
@property
|
|
188
|
+
def description(self) -> str | None:
|
|
189
|
+
"""Tenant description."""
|
|
190
|
+
return self._description
|
|
191
|
+
|
|
192
|
+
@description.setter
|
|
193
|
+
def description(self, value: str | None):
|
|
194
|
+
self._description = value
|
|
195
|
+
|
|
196
|
+
# Website
|
|
197
|
+
@property
|
|
198
|
+
def website(self) -> str | None:
|
|
199
|
+
"""Tenant website URL."""
|
|
200
|
+
return self._website
|
|
201
|
+
|
|
202
|
+
@website.setter
|
|
203
|
+
def website(self, value: str | None):
|
|
204
|
+
self._website = value
|
|
205
|
+
|
|
206
|
+
# Logo URL
|
|
207
|
+
@property
|
|
208
|
+
def logo_url(self) -> str | None:
|
|
209
|
+
"""Tenant logo URL."""
|
|
210
|
+
return self._logo_url
|
|
211
|
+
|
|
212
|
+
@logo_url.setter
|
|
213
|
+
def logo_url(self, value: str | None):
|
|
214
|
+
self._logo_url = value
|
|
215
|
+
|
|
216
|
+
# Helper Methods
|
|
217
|
+
|
|
218
|
+
def is_active(self) -> bool:
|
|
219
|
+
"""Check if tenant is active."""
|
|
220
|
+
return self._status == "active"
|
|
221
|
+
|
|
222
|
+
def is_inactive(self) -> bool:
|
|
223
|
+
"""Check if tenant is inactive."""
|
|
224
|
+
return self._status == "inactive"
|
|
225
|
+
|
|
226
|
+
def is_archived(self) -> bool:
|
|
227
|
+
"""Check if tenant is archived."""
|
|
228
|
+
return self._status == "archived"
|
|
229
|
+
|
|
230
|
+
def has_feature(self, feature_key: str) -> bool:
|
|
231
|
+
"""Check if tenant has a specific feature enabled."""
|
|
232
|
+
return self._features.get(feature_key, False) is True
|
|
233
|
+
|
|
234
|
+
def enable_feature(self, feature_key: str):
|
|
235
|
+
"""Enable a feature for this tenant."""
|
|
236
|
+
self._features[feature_key] = True
|
|
237
|
+
|
|
238
|
+
def disable_feature(self, feature_key: str):
|
|
239
|
+
"""Disable a feature for this tenant."""
|
|
240
|
+
self._features[feature_key] = False
|
|
241
|
+
|
|
242
|
+
def is_at_user_limit(self, current_user_count: int) -> bool:
|
|
243
|
+
"""Check if tenant is at maximum user limit."""
|
|
244
|
+
if self._max_users is None:
|
|
245
|
+
return False # Unlimited
|
|
246
|
+
return current_user_count >= self._max_users
|
|
247
|
+
|
|
248
|
+
def activate(self):
|
|
249
|
+
"""Set tenant status to active."""
|
|
250
|
+
self._status = "active"
|
|
251
|
+
|
|
252
|
+
def deactivate(self):
|
|
253
|
+
"""Set tenant status to inactive."""
|
|
254
|
+
self._status = "inactive"
|
|
255
|
+
|
|
256
|
+
def archive(self):
|
|
257
|
+
"""Set tenant status to archived."""
|
|
258
|
+
self._status = "archived"
|