fh-saas 0.9.5__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.
fh_saas/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.9.4"
fh_saas/_modidx.py ADDED
@@ -0,0 +1,201 @@
1
+ # Autogenerated by nbdev
2
+
3
+ d = { 'settings': { 'branch': 'main',
4
+ 'doc_baseurl': '/fh-saas',
5
+ 'doc_host': 'https://abhisheksreesaila.github.io',
6
+ 'git_url': 'https://github.com/abhisheksreesaila/fh-saas',
7
+ 'lib_path': 'fh_saas'},
8
+ 'syms': { 'fh_saas.core': {'fh_saas.core.foo': ('core.html#foo', 'fh_saas/core.py')},
9
+ 'fh_saas.db_host': { 'fh_saas.db_host.GlobalUser': ('db_host.html#globaluser', 'fh_saas/db_host.py'),
10
+ 'fh_saas.db_host.HostAuditLog': ('db_host.html#hostauditlog', 'fh_saas/db_host.py'),
11
+ 'fh_saas.db_host.HostDatabase': ('db_host.html#hostdatabase', 'fh_saas/db_host.py'),
12
+ 'fh_saas.db_host.HostDatabase.__init__': ('db_host.html#hostdatabase.__init__', 'fh_saas/db_host.py'),
13
+ 'fh_saas.db_host.HostDatabase.__new__': ('db_host.html#hostdatabase.__new__', 'fh_saas/db_host.py'),
14
+ 'fh_saas.db_host.HostDatabase.close': ('db_host.html#hostdatabase.close', 'fh_saas/db_host.py'),
15
+ 'fh_saas.db_host.HostDatabase.commit': ('db_host.html#hostdatabase.commit', 'fh_saas/db_host.py'),
16
+ 'fh_saas.db_host.HostDatabase.engine': ('db_host.html#hostdatabase.engine', 'fh_saas/db_host.py'),
17
+ 'fh_saas.db_host.HostDatabase.from_env': ('db_host.html#hostdatabase.from_env', 'fh_saas/db_host.py'),
18
+ 'fh_saas.db_host.HostDatabase.reset_instance': ( 'db_host.html#hostdatabase.reset_instance',
19
+ 'fh_saas/db_host.py'),
20
+ 'fh_saas.db_host.HostDatabase.rollback': ('db_host.html#hostdatabase.rollback', 'fh_saas/db_host.py'),
21
+ 'fh_saas.db_host.Membership': ('db_host.html#membership', 'fh_saas/db_host.py'),
22
+ 'fh_saas.db_host.Subscription': ('db_host.html#subscription', 'fh_saas/db_host.py'),
23
+ 'fh_saas.db_host.SystemJob': ('db_host.html#systemjob', 'fh_saas/db_host.py'),
24
+ 'fh_saas.db_host.TenantCatalog': ('db_host.html#tenantcatalog', 'fh_saas/db_host.py'),
25
+ 'fh_saas.db_host.gen_id': ('db_host.html#gen_id', 'fh_saas/db_host.py'),
26
+ 'fh_saas.db_host.timestamp': ('db_host.html#timestamp', 'fh_saas/db_host.py')},
27
+ 'fh_saas.db_tenant': { 'fh_saas.db_tenant.TenantPermission': ('db_tenant.html#tenantpermission', 'fh_saas/db_tenant.py'),
28
+ 'fh_saas.db_tenant.TenantSettings': ('db_tenant.html#tenantsettings', 'fh_saas/db_tenant.py'),
29
+ 'fh_saas.db_tenant.TenantUser': ('db_tenant.html#tenantuser', 'fh_saas/db_tenant.py'),
30
+ 'fh_saas.db_tenant.get_or_create_tenant_db': ( 'db_tenant.html#get_or_create_tenant_db',
31
+ 'fh_saas/db_tenant.py'),
32
+ 'fh_saas.db_tenant.init_tenant_core_schema': ( 'db_tenant.html#init_tenant_core_schema',
33
+ 'fh_saas/db_tenant.py')},
34
+ 'fh_saas.utils_api': { 'fh_saas.utils_api.AsyncAPIClient': ('utils_api.html#asyncapiclient', 'fh_saas/utils_api.py'),
35
+ 'fh_saas.utils_api.AsyncAPIClient.__aenter__': ( 'utils_api.html#asyncapiclient.__aenter__',
36
+ 'fh_saas/utils_api.py'),
37
+ 'fh_saas.utils_api.AsyncAPIClient.__aexit__': ( 'utils_api.html#asyncapiclient.__aexit__',
38
+ 'fh_saas/utils_api.py'),
39
+ 'fh_saas.utils_api.AsyncAPIClient.__init__': ( 'utils_api.html#asyncapiclient.__init__',
40
+ 'fh_saas/utils_api.py'),
41
+ 'fh_saas.utils_api.AsyncAPIClient.get_json': ( 'utils_api.html#asyncapiclient.get_json',
42
+ 'fh_saas/utils_api.py'),
43
+ 'fh_saas.utils_api.AsyncAPIClient.request': ( 'utils_api.html#asyncapiclient.request',
44
+ 'fh_saas/utils_api.py'),
45
+ 'fh_saas.utils_api._should_retry_on_status': ( 'utils_api.html#_should_retry_on_status',
46
+ 'fh_saas/utils_api.py'),
47
+ 'fh_saas.utils_api.api_key_auth': ('utils_api.html#api_key_auth', 'fh_saas/utils_api.py'),
48
+ 'fh_saas.utils_api.bearer_token_auth': ('utils_api.html#bearer_token_auth', 'fh_saas/utils_api.py'),
49
+ 'fh_saas.utils_api.oauth_token_auth': ('utils_api.html#oauth_token_auth', 'fh_saas/utils_api.py')},
50
+ 'fh_saas.utils_auth': { 'fh_saas.utils_auth._get_cached_auth': ('utils_auth.html#_get_cached_auth', 'fh_saas/utils_auth.py'),
51
+ 'fh_saas.utils_auth._set_auth_cache': ('utils_auth.html#_set_auth_cache', 'fh_saas/utils_auth.py'),
52
+ 'fh_saas.utils_auth.clear_session': ('utils_auth.html#clear_session', 'fh_saas/utils_auth.py'),
53
+ 'fh_saas.utils_auth.create_auth_beforeware': ( 'utils_auth.html#create_auth_beforeware',
54
+ 'fh_saas/utils_auth.py'),
55
+ 'fh_saas.utils_auth.create_or_get_global_user': ( 'utils_auth.html#create_or_get_global_user',
56
+ 'fh_saas/utils_auth.py'),
57
+ 'fh_saas.utils_auth.create_user_session': ( 'utils_auth.html#create_user_session',
58
+ 'fh_saas/utils_auth.py'),
59
+ 'fh_saas.utils_auth.generate_oauth_state': ( 'utils_auth.html#generate_oauth_state',
60
+ 'fh_saas/utils_auth.py'),
61
+ 'fh_saas.utils_auth.get_current_user': ('utils_auth.html#get_current_user', 'fh_saas/utils_auth.py'),
62
+ 'fh_saas.utils_auth.get_google_oauth_client': ( 'utils_auth.html#get_google_oauth_client',
63
+ 'fh_saas/utils_auth.py'),
64
+ 'fh_saas.utils_auth.get_user_membership': ( 'utils_auth.html#get_user_membership',
65
+ 'fh_saas/utils_auth.py'),
66
+ 'fh_saas.utils_auth.get_user_role': ('utils_auth.html#get_user_role', 'fh_saas/utils_auth.py'),
67
+ 'fh_saas.utils_auth.handle_login_request': ( 'utils_auth.html#handle_login_request',
68
+ 'fh_saas/utils_auth.py'),
69
+ 'fh_saas.utils_auth.handle_logout': ('utils_auth.html#handle_logout', 'fh_saas/utils_auth.py'),
70
+ 'fh_saas.utils_auth.handle_oauth_callback': ( 'utils_auth.html#handle_oauth_callback',
71
+ 'fh_saas/utils_auth.py'),
72
+ 'fh_saas.utils_auth.has_min_role': ('utils_auth.html#has_min_role', 'fh_saas/utils_auth.py'),
73
+ 'fh_saas.utils_auth.invalidate_auth_cache': ( 'utils_auth.html#invalidate_auth_cache',
74
+ 'fh_saas/utils_auth.py'),
75
+ 'fh_saas.utils_auth.provision_new_user': ( 'utils_auth.html#provision_new_user',
76
+ 'fh_saas/utils_auth.py'),
77
+ 'fh_saas.utils_auth.require_role': ('utils_auth.html#require_role', 'fh_saas/utils_auth.py'),
78
+ 'fh_saas.utils_auth.require_tenant_access': ( 'utils_auth.html#require_tenant_access',
79
+ 'fh_saas/utils_auth.py'),
80
+ 'fh_saas.utils_auth.route_user_after_login': ( 'utils_auth.html#route_user_after_login',
81
+ 'fh_saas/utils_auth.py'),
82
+ 'fh_saas.utils_auth.verify_membership': ('utils_auth.html#verify_membership', 'fh_saas/utils_auth.py'),
83
+ 'fh_saas.utils_auth.verify_oauth_state': ( 'utils_auth.html#verify_oauth_state',
84
+ 'fh_saas/utils_auth.py')},
85
+ 'fh_saas.utils_bgtsk': { 'fh_saas.utils_bgtsk.BackgroundTaskManager': ( 'utils_bgtsk.html#backgroundtaskmanager',
86
+ 'fh_saas/utils_bgtsk.py'),
87
+ 'fh_saas.utils_bgtsk.BackgroundTaskManager.__init__': ( 'utils_bgtsk.html#backgroundtaskmanager.__init__',
88
+ 'fh_saas/utils_bgtsk.py'),
89
+ 'fh_saas.utils_bgtsk.BackgroundTaskManager._execute_with_retry': ( 'utils_bgtsk.html#backgroundtaskmanager._execute_with_retry',
90
+ 'fh_saas/utils_bgtsk.py'),
91
+ 'fh_saas.utils_bgtsk.BackgroundTaskManager._handle_failure': ( 'utils_bgtsk.html#backgroundtaskmanager._handle_failure',
92
+ 'fh_saas/utils_bgtsk.py'),
93
+ 'fh_saas.utils_bgtsk.BackgroundTaskManager.get_job': ( 'utils_bgtsk.html#backgroundtaskmanager.get_job',
94
+ 'fh_saas/utils_bgtsk.py'),
95
+ 'fh_saas.utils_bgtsk.BackgroundTaskManager.list_jobs': ( 'utils_bgtsk.html#backgroundtaskmanager.list_jobs',
96
+ 'fh_saas/utils_bgtsk.py'),
97
+ 'fh_saas.utils_bgtsk.BackgroundTaskManager.submit': ( 'utils_bgtsk.html#backgroundtaskmanager.submit',
98
+ 'fh_saas/utils_bgtsk.py'),
99
+ 'fh_saas.utils_bgtsk.TenantJob': ('utils_bgtsk.html#tenantjob', 'fh_saas/utils_bgtsk.py')},
100
+ 'fh_saas.utils_blog': { 'fh_saas.utils_blog.MarkdownEngine': ('utils_blog.html#markdownengine', 'fh_saas/utils_blog.py'),
101
+ 'fh_saas.utils_blog.MarkdownEngine.__init__': ( 'utils_blog.html#markdownengine.__init__',
102
+ 'fh_saas/utils_blog.py'),
103
+ 'fh_saas.utils_blog.MarkdownEngine.get_toc': ( 'utils_blog.html#markdownengine.get_toc',
104
+ 'fh_saas/utils_blog.py'),
105
+ 'fh_saas.utils_blog.MarkdownEngine.render': ( 'utils_blog.html#markdownengine.render',
106
+ 'fh_saas/utils_blog.py'),
107
+ 'fh_saas.utils_blog.PostLoader': ('utils_blog.html#postloader', 'fh_saas/utils_blog.py'),
108
+ 'fh_saas.utils_blog.PostLoader.__init__': ( 'utils_blog.html#postloader.__init__',
109
+ 'fh_saas/utils_blog.py'),
110
+ 'fh_saas.utils_blog.PostLoader.get_post': ( 'utils_blog.html#postloader.get_post',
111
+ 'fh_saas/utils_blog.py'),
112
+ 'fh_saas.utils_blog.PostLoader.load_posts': ( 'utils_blog.html#postloader.load_posts',
113
+ 'fh_saas/utils_blog.py'),
114
+ 'fh_saas.utils_blog._generate_slug': ('utils_blog.html#_generate_slug', 'fh_saas/utils_blog.py'),
115
+ 'fh_saas.utils_blog._parse_date': ('utils_blog.html#_parse_date', 'fh_saas/utils_blog.py')},
116
+ 'fh_saas.utils_db': { 'fh_saas.utils_db.create_index': ('utils_db.html#create_index', 'fh_saas/utils_db.py'),
117
+ 'fh_saas.utils_db.create_indexes': ('utils_db.html#create_indexes', 'fh_saas/utils_db.py'),
118
+ 'fh_saas.utils_db.drop_index': ('utils_db.html#drop_index', 'fh_saas/utils_db.py'),
119
+ 'fh_saas.utils_db.drop_table': ('utils_db.html#drop_table', 'fh_saas/utils_db.py'),
120
+ 'fh_saas.utils_db.register_table': ('utils_db.html#register_table', 'fh_saas/utils_db.py'),
121
+ 'fh_saas.utils_db.register_tables': ('utils_db.html#register_tables', 'fh_saas/utils_db.py'),
122
+ 'fh_saas.utils_db.table_exists': ('utils_db.html#table_exists', 'fh_saas/utils_db.py')},
123
+ 'fh_saas.utils_email': { 'fh_saas.utils_email.get_smtp_config': ('utils_email.html#get_smtp_config', 'fh_saas/utils_email.py'),
124
+ 'fh_saas.utils_email.get_template_path': ( 'utils_email.html#get_template_path',
125
+ 'fh_saas/utils_email.py'),
126
+ 'fh_saas.utils_email.load_template': ('utils_email.html#load_template', 'fh_saas/utils_email.py'),
127
+ 'fh_saas.utils_email.send_batch_emails': ( 'utils_email.html#send_batch_emails',
128
+ 'fh_saas/utils_email.py'),
129
+ 'fh_saas.utils_email.send_email': ('utils_email.html#send_email', 'fh_saas/utils_email.py'),
130
+ 'fh_saas.utils_email.send_invitation_email': ( 'utils_email.html#send_invitation_email',
131
+ 'fh_saas/utils_email.py'),
132
+ 'fh_saas.utils_email.send_password_reset_email': ( 'utils_email.html#send_password_reset_email',
133
+ 'fh_saas/utils_email.py'),
134
+ 'fh_saas.utils_email.send_welcome_email': ( 'utils_email.html#send_welcome_email',
135
+ 'fh_saas/utils_email.py')},
136
+ 'fh_saas.utils_graphql': { 'fh_saas.utils_graphql.GraphQLClient': ( 'utils_graphql.html#graphqlclient',
137
+ 'fh_saas/utils_graphql.py'),
138
+ 'fh_saas.utils_graphql.GraphQLClient.__init__': ( 'utils_graphql.html#graphqlclient.__init__',
139
+ 'fh_saas/utils_graphql.py'),
140
+ 'fh_saas.utils_graphql.GraphQLClient._get_nested_value': ( 'utils_graphql.html#graphqlclient._get_nested_value',
141
+ 'fh_saas/utils_graphql.py'),
142
+ 'fh_saas.utils_graphql.GraphQLClient.execute': ( 'utils_graphql.html#graphqlclient.execute',
143
+ 'fh_saas/utils_graphql.py'),
144
+ 'fh_saas.utils_graphql.GraphQLClient.execute_mutation': ( 'utils_graphql.html#graphqlclient.execute_mutation',
145
+ 'fh_saas/utils_graphql.py'),
146
+ 'fh_saas.utils_graphql.GraphQLClient.execute_query': ( 'utils_graphql.html#graphqlclient.execute_query',
147
+ 'fh_saas/utils_graphql.py'),
148
+ 'fh_saas.utils_graphql.GraphQLClient.fetch_pages_generator': ( 'utils_graphql.html#graphqlclient.fetch_pages_generator',
149
+ 'fh_saas/utils_graphql.py'),
150
+ 'fh_saas.utils_graphql.GraphQLClient.fetch_pages_relay': ( 'utils_graphql.html#graphqlclient.fetch_pages_relay',
151
+ 'fh_saas/utils_graphql.py'),
152
+ 'fh_saas.utils_graphql.GraphQLClient.from_url': ( 'utils_graphql.html#graphqlclient.from_url',
153
+ 'fh_saas/utils_graphql.py'),
154
+ 'fh_saas.utils_graphql.execute_graphql': ( 'utils_graphql.html#execute_graphql',
155
+ 'fh_saas/utils_graphql.py')},
156
+ 'fh_saas.utils_log': {'fh_saas.utils_log.configure_logging': ('utils_log.html#configure_logging', 'fh_saas/utils_log.py')},
157
+ 'fh_saas.utils_polars_mapper': { 'fh_saas.utils_polars_mapper.apply_schema': ( 'utils_polars_mapper.html#apply_schema',
158
+ 'fh_saas/utils_polars_mapper.py'),
159
+ 'fh_saas.utils_polars_mapper.map_and_upsert': ( 'utils_polars_mapper.html#map_and_upsert',
160
+ 'fh_saas/utils_polars_mapper.py')},
161
+ 'fh_saas.utils_seo': { 'fh_saas.utils_seo.generate_head_tags': ('utils_seo.html#generate_head_tags', 'fh_saas/utils_seo.py'),
162
+ 'fh_saas.utils_seo.generate_rss_xml': ('utils_seo.html#generate_rss_xml', 'fh_saas/utils_seo.py'),
163
+ 'fh_saas.utils_seo.generate_sitemap_xml': ( 'utils_seo.html#generate_sitemap_xml',
164
+ 'fh_saas/utils_seo.py')},
165
+ 'fh_saas.utils_sql': { 'fh_saas.utils_sql._extract_params': ('utils_sql.html#_extract_params', 'fh_saas/utils_sql.py'),
166
+ 'fh_saas.utils_sql.batch_execute': ('utils_sql.html#batch_execute', 'fh_saas/utils_sql.py'),
167
+ 'fh_saas.utils_sql.bulk_delete': ('utils_sql.html#bulk_delete', 'fh_saas/utils_sql.py'),
168
+ 'fh_saas.utils_sql.bulk_insert_only': ('utils_sql.html#bulk_insert_only', 'fh_saas/utils_sql.py'),
169
+ 'fh_saas.utils_sql.bulk_upsert': ('utils_sql.html#bulk_upsert', 'fh_saas/utils_sql.py'),
170
+ 'fh_saas.utils_sql.delete_record': ('utils_sql.html#delete_record', 'fh_saas/utils_sql.py'),
171
+ 'fh_saas.utils_sql.from_cents': ('utils_sql.html#from_cents', 'fh_saas/utils_sql.py'),
172
+ 'fh_saas.utils_sql.get_by_id': ('utils_sql.html#get_by_id', 'fh_saas/utils_sql.py'),
173
+ 'fh_saas.utils_sql.get_db_type': ('utils_sql.html#get_db_type', 'fh_saas/utils_sql.py'),
174
+ 'fh_saas.utils_sql.insert_only': ('utils_sql.html#insert_only', 'fh_saas/utils_sql.py'),
175
+ 'fh_saas.utils_sql.paginate_sql': ('utils_sql.html#paginate_sql', 'fh_saas/utils_sql.py'),
176
+ 'fh_saas.utils_sql.run_id': ('utils_sql.html#run_id', 'fh_saas/utils_sql.py'),
177
+ 'fh_saas.utils_sql.to_cents': ('utils_sql.html#to_cents', 'fh_saas/utils_sql.py'),
178
+ 'fh_saas.utils_sql.update_record': ('utils_sql.html#update_record', 'fh_saas/utils_sql.py'),
179
+ 'fh_saas.utils_sql.upsert': ('utils_sql.html#upsert', 'fh_saas/utils_sql.py'),
180
+ 'fh_saas.utils_sql.validate_params': ('utils_sql.html#validate_params', 'fh_saas/utils_sql.py'),
181
+ 'fh_saas.utils_sql.with_transaction': ('utils_sql.html#with_transaction', 'fh_saas/utils_sql.py')},
182
+ 'fh_saas.utils_sync': { 'fh_saas.utils_sync.sync_external_data': ( 'utils_sync.html#sync_external_data',
183
+ 'fh_saas/utils_sync.py'),
184
+ 'fh_saas.utils_sync.sync_incremental': ('utils_sync.html#sync_incremental', 'fh_saas/utils_sync.py')},
185
+ 'fh_saas.utils_webhook': { 'fh_saas.utils_webhook.check_idempotency': ( 'utils_webhook.html#check_idempotency',
186
+ 'fh_saas/utils_webhook.py'),
187
+ 'fh_saas.utils_webhook.handle_webhook_request': ( 'utils_webhook.html#handle_webhook_request',
188
+ 'fh_saas/utils_webhook.py'),
189
+ 'fh_saas.utils_webhook.log_webhook_event': ( 'utils_webhook.html#log_webhook_event',
190
+ 'fh_saas/utils_webhook.py'),
191
+ 'fh_saas.utils_webhook.process_webhook': ( 'utils_webhook.html#process_webhook',
192
+ 'fh_saas/utils_webhook.py'),
193
+ 'fh_saas.utils_webhook.update_webhook_status': ( 'utils_webhook.html#update_webhook_status',
194
+ 'fh_saas/utils_webhook.py'),
195
+ 'fh_saas.utils_webhook.verify_webhook_signature': ( 'utils_webhook.html#verify_webhook_signature',
196
+ 'fh_saas/utils_webhook.py')},
197
+ 'fh_saas.utils_workflow': { 'fh_saas.utils_workflow.Workflow': ('utils_workflow.html#workflow', 'fh_saas/utils_workflow.py'),
198
+ 'fh_saas.utils_workflow.Workflow.__init__': ( 'utils_workflow.html#workflow.__init__',
199
+ 'fh_saas/utils_workflow.py'),
200
+ 'fh_saas.utils_workflow.Workflow.execute': ( 'utils_workflow.html#workflow.execute',
201
+ 'fh_saas/utils_workflow.py')}}}
fh_saas/core.py ADDED
@@ -0,0 +1,9 @@
1
+ """Fill in a module description here"""
2
+
3
+ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_core.ipynb.
4
+
5
+ # %% auto 0
6
+ __all__ = ['foo']
7
+
8
+ # %% ../nbs/00_core.ipynb 3
9
+ def foo(): pass
fh_saas/db_host.py ADDED
@@ -0,0 +1,153 @@
1
+ """The central registry for multi-tenant SaaS applications - managing users, tenants, and access control."""
2
+
3
+ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_db_host.ipynb.
4
+
5
+ # %% auto 0
6
+ __all__ = ['timestamp', 'gen_id', 'GlobalUser', 'TenantCatalog', 'Membership', 'Subscription', 'HostAuditLog', 'SystemJob',
7
+ 'HostDatabase']
8
+
9
+ # %% ../nbs/00_db_host.ipynb 2
10
+ from fastsql import *
11
+ from fastcore.utils import *
12
+ import uuid
13
+ import os
14
+ import urllib.parse
15
+ from datetime import datetime
16
+ from nbdev.showdoc import show_doc
17
+
18
+ # %% ../nbs/00_db_host.ipynb 5
19
+ def timestamp(): return datetime.utcnow().isoformat()
20
+ def gen_id(): return uuid.uuid4().hex
21
+
22
+ # %% ../nbs/00_db_host.ipynb 7
23
+ class GlobalUser:
24
+ """Identity: Who is this person?"""
25
+ id: str; email: str; oauth_id: str
26
+ password_hash: str = None; stripe_cust_id: str = None
27
+ is_sys_admin: bool = False; created_at: str; last_login: str = None
28
+
29
+ class TenantCatalog:
30
+ """Registry: Where is the database?"""
31
+ id: str; name: str; db_url: str
32
+ is_active: bool = True; plan_tier: str = "free"; created_at: str
33
+
34
+ class Membership:
35
+ """Router: Which tenants can they access?"""
36
+ id: str; user_id: str; tenant_id: str; profile_id: str
37
+ meta_attributes: str = None; role: str = "member"
38
+ is_active: bool = True; created_at: str
39
+
40
+ class Subscription:
41
+ """Billing: Are they allowed to use the app?"""
42
+ id: str; tenant_id: str
43
+ stripe_sub_id: str; stripe_cust_id: str
44
+ plan_tier: str; status: str
45
+ current_period_end: str; cancel_at_period_end: bool = False
46
+
47
+ class HostAuditLog:
48
+ """Security: Who changed the system?"""
49
+ id: str; actor_user_id: str; event_type: str
50
+ target_id: str = None; details: str = None; ip_address: str = None
51
+ created_at: str
52
+
53
+ class SystemJob:
54
+ """Maintenance: Provisioning & Cleanups"""
55
+ id: str; job_type: str; status: str
56
+ payload: str = None; error_log: str = None
57
+ created_at: str; completed_at: str = None
58
+
59
+
60
+
61
+ # %% ../nbs/00_db_host.ipynb 10
62
+ class HostDatabase:
63
+ """Singleton connection manager for the host database."""
64
+ _instance = None
65
+
66
+ def __new__(cls, db_url: str = None):
67
+ """Singleton pattern - only one instance per application."""
68
+ if cls._instance is None:
69
+ cls._instance = super().__new__(cls)
70
+ cls._instance._initialized = False
71
+ return cls._instance
72
+
73
+ def __init__(self, db_url: str = None):
74
+ """Initialize database connection and create table objects."""
75
+ if self._initialized:
76
+ return
77
+
78
+ if db_url is None:
79
+ raise ValueError("db_url required for first initialization")
80
+
81
+ # Create database connection
82
+ self.db = Database(db_url)
83
+
84
+ # Create table objects for host schema
85
+ self.global_users = self.db.create(GlobalUser, name="core_users", pk='id')
86
+ self.tenant_catalogs = self.db.create(TenantCatalog, name="core_tenants", pk='id')
87
+ self.memberships = self.db.create(Membership, name="core_memberships", pk='id')
88
+ self.subscriptions = self.db.create(Subscription, name="core_subscriptions", pk='id')
89
+ self.audit_logs = self.db.create(HostAuditLog, name="sys_audit_logs", pk='id')
90
+ self.system_jobs = self.db.create(SystemJob, name="sys_jobs", pk='id')
91
+
92
+ self._initialized = True
93
+
94
+ @property
95
+ def engine(self):
96
+ """Get the underlying SQLAlchemy engine."""
97
+ return self.db.engine
98
+
99
+ @classmethod
100
+ def from_env(cls):
101
+ """Create HostDatabase from DB_* environment variables."""
102
+ DB_TYPE = os.getenv("DB_TYPE", "POSTGRESQL")
103
+ DB_USER = os.getenv("DB_USER", "postgres")
104
+ DB_PASS = os.getenv("DB_PASS", "")
105
+ DB_HOST = os.getenv("DB_HOST", "localhost")
106
+ DB_PORT = os.getenv("DB_PORT", "5432")
107
+ DB_NAME = os.getenv("DB_NAME", "app_host")
108
+
109
+ if DB_TYPE == "POSTGRESQL":
110
+ if not DB_PASS:
111
+ raise ValueError("DB_PASS is required for PostgreSQL")
112
+ encoded_pass = urllib.parse.quote_plus(DB_PASS)
113
+ db_url = f"postgresql://{DB_USER}:{encoded_pass}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
114
+ else:
115
+ db_url = f"sqlite:///{DB_NAME}.db"
116
+
117
+ return cls(db_url)
118
+
119
+ def commit(self):
120
+ """Commit current transaction."""
121
+ self.db.conn.commit()
122
+
123
+ def rollback(self):
124
+ """Rollback current transaction."""
125
+ self.db.conn.rollback()
126
+
127
+ def close(self):
128
+ """Close database connection and dispose engine.
129
+
130
+ Call this when shutting down or before reset_instance() in tests.
131
+ """
132
+ try:
133
+ self.db.conn.close()
134
+ except Exception:
135
+ pass
136
+ try:
137
+ self.db.engine.dispose()
138
+ except Exception:
139
+ pass
140
+ self._initialized = False
141
+
142
+ @classmethod
143
+ def reset_instance(cls):
144
+ """Reset singleton instance (testing only).
145
+
146
+ ⚠️ Call close() first to release database connections!
147
+ """
148
+ if cls._instance is not None:
149
+ try:
150
+ cls._instance.close()
151
+ except Exception:
152
+ pass
153
+ cls._instance = None
fh_saas/db_tenant.py ADDED
@@ -0,0 +1,142 @@
1
+ """Isolated per-tenant data storage with user profiles, permissions, and settings."""
2
+
3
+ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/01_db_tenant.ipynb.
4
+
5
+ # %% auto 0
6
+ __all__ = ['logger', 'get_or_create_tenant_db', 'TenantUser', 'TenantPermission', 'TenantSettings', 'init_tenant_core_schema']
7
+
8
+ # %% ../nbs/01_db_tenant.ipynb 2
9
+ from fastsql import *
10
+ from fastcore.utils import *
11
+ from .db_host import timestamp, gen_id
12
+ import urllib.parse
13
+ import os
14
+ import logging
15
+
16
+ # Module-level logger - configured by app via configure_logging()
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # %% ../nbs/01_db_tenant.ipynb 6
20
+ def get_or_create_tenant_db(tenant_id: str, tenant_name: str = None):
21
+ """Get or create a tenant database connection by tenant ID.
22
+
23
+ ⚠️ IMPORTANT: Caller is responsible for closing the returned Database connection
24
+ by calling `db.conn.close()` and `db.engine.dispose()` when done.
25
+ """
26
+ from sqlalchemy import text
27
+
28
+ # Connect to host - read from environment
29
+ DB_TYPE = os.getenv("DB_TYPE", "POSTGRESQL")
30
+ DB_USER = os.getenv("DB_USER", "postgres")
31
+ DB_PASS = os.getenv("DB_PASS", "")
32
+ DB_HOST = os.getenv("DB_HOST", "localhost")
33
+ DB_PORT = os.getenv("DB_PORT", "5432")
34
+ DB_NAME = os.getenv("DB_NAME", "app_host") # Host database name
35
+
36
+ # Build host database connection
37
+ if DB_TYPE == "POSTGRESQL":
38
+ if not DB_PASS:
39
+ raise ValueError("DB_PASS is required for PostgreSQL")
40
+ encoded_pass = urllib.parse.quote_plus(DB_PASS)
41
+ host_url = f"postgresql://{DB_USER}:{encoded_pass}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
42
+ else:
43
+ host_url = f"sqlite:///{DB_NAME}.db"
44
+
45
+ # Use try/finally to ensure host_db connection is always closed
46
+ host_db = Database(host_url)
47
+ try:
48
+ # Check if tenant registered
49
+ class TenantCatalog:
50
+ id: str; name: str; db_url: str
51
+ is_active: bool = True; plan_tier: str = "free"; created_at: str
52
+
53
+ tenant_catalogs = host_db.create(TenantCatalog, name="core_tenants", pk='id')
54
+ host_db.conn.rollback()
55
+ all_tenants = tenant_catalogs()
56
+ existing = [t for t in all_tenants if t.id == tenant_id]
57
+
58
+ # Build tenant database connection
59
+ # PostgreSQL databases cannot start with numbers, so prefix with 't_'
60
+ if DB_TYPE == "POSTGRESQL":
61
+ tenant_db_name = f"t_{tenant_id}_db"
62
+ tenant_url = f"postgresql://{DB_USER}:{encoded_pass}@{DB_HOST}:{DB_PORT}/{tenant_db_name}"
63
+ else:
64
+ tenant_db_name = f"{tenant_id}_db"
65
+ tenant_url = f"sqlite:///{tenant_db_name}.db"
66
+
67
+ if not existing:
68
+ print(f"⚡ Creating new tenant: {tenant_id}")
69
+
70
+ # Create physical database (PostgreSQL only)
71
+ if DB_TYPE == "POSTGRESQL":
72
+ with host_db.engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
73
+ try:
74
+ conn.execute(text(f"CREATE DATABASE {tenant_db_name}"))
75
+ print(f" ✅ Database created: {tenant_db_name}")
76
+ except Exception as e:
77
+ if "already exists" not in str(e):
78
+ raise
79
+
80
+ # Register in host
81
+ new_tenant = TenantCatalog(
82
+ id=tenant_id,
83
+ name=tenant_name or tenant_id,
84
+ db_url=tenant_url,
85
+ created_at=timestamp()
86
+ )
87
+ tenant_catalogs.insert(new_tenant)
88
+ host_db.conn.commit()
89
+ print(f" ✅ Registered in host DB")
90
+ else:
91
+ print(f"ℹ️ Tenant exists: {existing[0].name}")
92
+ tenant_url = existing[0].db_url
93
+
94
+ return Database(tenant_url)
95
+ finally:
96
+ # Always close the internal host_db connection to prevent leaks
97
+ try:
98
+ host_db.conn.close()
99
+ host_db.engine.dispose()
100
+ except Exception:
101
+ pass # Ignore cleanup errors
102
+
103
+ # %% ../nbs/01_db_tenant.ipynb 10
104
+ class TenantUser:
105
+ """Local user profile linked to GlobalUser in host database."""
106
+ id: str # MUST match GlobalUser.id from host DB
107
+ display_name: str # e.g. "John Doe"
108
+ local_role: str # 'admin', 'editor', 'viewer'
109
+ preferences: str = None # JSON settings
110
+ last_active: str = None
111
+ created_at: str
112
+
113
+ class TenantPermission:
114
+ """Fine-grained resource permission for a tenant user."""
115
+ id: str
116
+ user_id: str # Links to TenantUser.id
117
+ resource: str # 'transactions', 'budgets', 'reports'
118
+ action: str # 'view', 'edit', 'delete'
119
+ granted: bool = True
120
+ created_at: str
121
+
122
+ class TenantSettings:
123
+ """Tenant-wide configuration and feature flags."""
124
+ id: str = "default"
125
+ tenant_name: str
126
+ timezone: str = "UTC"
127
+ currency: str = "USD"
128
+ feature_flags: str = None # JSON
129
+ updated_at: str
130
+
131
+ # %% ../nbs/01_db_tenant.ipynb 12
132
+ def init_tenant_core_schema(tenant_db: Database):
133
+ """Create all core tenant tables and return table accessors."""
134
+ tenant_users = tenant_db.create(TenantUser, name="core_tenant_users", pk='id')
135
+ permissions = tenant_db.create(TenantPermission, name="core_permissions", pk='id')
136
+ settings = tenant_db.create(TenantSettings, name="core_settings", pk='id')
137
+
138
+ return {
139
+ 'tenant_users': tenant_users,
140
+ 'permissions': permissions,
141
+ 'settings': settings
142
+ }
fh_saas/utils_api.py ADDED
@@ -0,0 +1,109 @@
1
+ """Async HTTP client with automatic retry logic and auth helpers."""
2
+
3
+ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/07_utils_api.ipynb.
4
+
5
+ # %% ../nbs/07_utils_api.ipynb 2
6
+ from __future__ import annotations
7
+ import httpx
8
+ from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception
9
+ from typing import Optional, Dict, Any
10
+ import logging
11
+ from nbdev.showdoc import show_doc
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Custom retry condition: only retry on 429 or 500+ status codes
16
+ def _should_retry_on_status(exception):
17
+ """Only retry on 429 rate limit or 500+ server errors"""
18
+ if isinstance(exception, httpx.HTTPStatusError):
19
+ return exception.response.status_code == 429 or exception.response.status_code >= 500
20
+ if isinstance(exception, httpx.RequestError):
21
+ return True # Always retry network errors
22
+ return False
23
+
24
+ # %% auto 0
25
+ __all__ = ['logger', 'AsyncAPIClient', 'bearer_token_auth', 'api_key_auth', 'oauth_token_auth']
26
+
27
+ # %% ../nbs/07_utils_api.ipynb 5
28
+ class AsyncAPIClient:
29
+ """Async HTTP client with retry logic for external API integrations."""
30
+
31
+ def __init__(
32
+ self,
33
+ base_url: str,
34
+ auth_headers: dict = None,
35
+ timeout: int = 30
36
+ ):
37
+ """Initialize client with base URL, optional auth headers, and timeout."""
38
+ self.base_url = base_url.rstrip('/')
39
+ self.auth_headers = auth_headers or {}
40
+ self.timeout = timeout
41
+ self.client = None
42
+
43
+ async def __aenter__(self):
44
+ """Async context manager entry - creates httpx client."""
45
+ self.client = httpx.AsyncClient(
46
+ base_url=self.base_url,
47
+ headers=self.auth_headers,
48
+ timeout=self.timeout
49
+ )
50
+ return self
51
+
52
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
53
+ """Async context manager exit - closes httpx client."""
54
+ if self.client:
55
+ await self.client.aclose()
56
+
57
+ @retry(
58
+ stop=stop_after_attempt(3),
59
+ wait=wait_exponential(multiplier=1, min=2, max=10),
60
+ retry=retry_if_exception(_should_retry_on_status),
61
+ reraise=True
62
+ )
63
+ async def request(
64
+ self,
65
+ method: str,
66
+ endpoint: str,
67
+ params: dict = None,
68
+ json: dict = None,
69
+ headers: dict = None
70
+ ) -> httpx.Response:
71
+ """Execute HTTP request with automatic retry on 429/500+ errors."""
72
+ if not self.client:
73
+ raise RuntimeError("Client not initialized. Use 'async with' context manager.")
74
+
75
+ request_headers = {**self.auth_headers, **(headers or {})}
76
+
77
+ response = await self.client.request(
78
+ method=method,
79
+ url=endpoint,
80
+ params=params,
81
+ json=json,
82
+ headers=request_headers
83
+ )
84
+ response.raise_for_status()
85
+ return response
86
+
87
+ async def get_json(
88
+ self,
89
+ endpoint: str,
90
+ params: dict = None
91
+ ) -> Dict[str, Any]:
92
+ """Convenience GET that returns parsed JSON."""
93
+ response = await self.request('GET', endpoint, params=params)
94
+ return response.json()
95
+
96
+ # %% ../nbs/07_utils_api.ipynb 10
97
+ def bearer_token_auth(token: str) -> dict:
98
+ """Generate Bearer token authentication header."""
99
+ return {'Authorization': f'Bearer {token}'}
100
+
101
+
102
+ def api_key_auth(api_key: str, header_name: str = 'X-API-Key') -> dict:
103
+ """Generate API key header with customizable header name."""
104
+ return {header_name: api_key}
105
+
106
+
107
+ def oauth_token_auth(access_token: str) -> dict:
108
+ """Generate OAuth 2.0 access token header (alias for bearer_token_auth)."""
109
+ return bearer_token_auth(access_token)