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 +1 -0
- fh_saas/_modidx.py +201 -0
- fh_saas/core.py +9 -0
- fh_saas/db_host.py +153 -0
- fh_saas/db_tenant.py +142 -0
- fh_saas/utils_api.py +109 -0
- fh_saas/utils_auth.py +647 -0
- fh_saas/utils_bgtsk.py +112 -0
- fh_saas/utils_blog.py +147 -0
- fh_saas/utils_db.py +151 -0
- fh_saas/utils_email.py +327 -0
- fh_saas/utils_graphql.py +257 -0
- fh_saas/utils_log.py +56 -0
- fh_saas/utils_polars_mapper.py +134 -0
- fh_saas/utils_seo.py +230 -0
- fh_saas/utils_sql.py +320 -0
- fh_saas/utils_sync.py +115 -0
- fh_saas/utils_webhook.py +216 -0
- fh_saas/utils_workflow.py +23 -0
- fh_saas-0.9.5.dist-info/METADATA +274 -0
- fh_saas-0.9.5.dist-info/RECORD +25 -0
- fh_saas-0.9.5.dist-info/WHEEL +5 -0
- fh_saas-0.9.5.dist-info/entry_points.txt +2 -0
- fh_saas-0.9.5.dist-info/licenses/LICENSE +201 -0
- fh_saas-0.9.5.dist-info/top_level.txt +1 -0
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
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)
|