db-analytics-tools 0.2__tar.gz → 0.2.1__tar.gz

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.
Files changed (24) hide show
  1. {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/PKG-INFO +2 -1
  2. {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools/__init__.py +1 -1
  3. {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools/airflow.py +52 -4
  4. db_analytics_tools-0.2.1/db_analytics_tools/mail.py +218 -0
  5. {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools/webapp.py +170 -56
  6. {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools.egg-info/PKG-INFO +2 -1
  7. {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools.egg-info/requires.txt +1 -0
  8. {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/setup.py +2 -1
  9. db_analytics_tools-0.2/db_analytics_tools/mail.py +0 -76
  10. {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/LICENSE +0 -0
  11. {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/README.md +0 -0
  12. {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools/analytics.py +0 -0
  13. {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools/cli.py +0 -0
  14. {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools/integration.py +0 -0
  15. {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools/learning.py +0 -0
  16. {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools/plotting.py +0 -0
  17. {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools/scheduler.py +0 -0
  18. {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools/utils.py +0 -0
  19. {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools/webapp_v1.py +0 -0
  20. {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools.egg-info/SOURCES.txt +0 -0
  21. {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools.egg-info/dependency_links.txt +0 -0
  22. {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools.egg-info/entry_points.txt +0 -0
  23. {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools.egg-info/top_level.txt +0 -0
  24. {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: db_analytics_tools
3
- Version: 0.2
3
+ Version: 0.2.1
4
4
  Summary: Databases Tools for Data Analytics
5
5
  Home-page: https://joekakone.github.io/db-analytics-tools
6
6
  Download-URL: https://github.com/joekakone/db-analytics-tools
@@ -22,6 +22,7 @@ Requires-Dist: streamlit>=1.32.2
22
22
  Requires-Dist: matplotlib>=3.4.3
23
23
  Requires-Dist: statsmodels>=0.13.5
24
24
  Requires-Dist: python-crontab>=3.1.0
25
+ Requires-Dist: apache-airflow>=3.1.0
25
26
  Dynamic: author
26
27
  Dynamic: author-email
27
28
  Dynamic: description
@@ -7,4 +7,4 @@
7
7
 
8
8
  from .utils import Client
9
9
 
10
- __version__ = "0.2"
10
+ __version__ = "0.2.1"
@@ -6,14 +6,27 @@
6
6
  This module provides a class for interacting with the Apache Airflow REST API.
7
7
  """
8
8
 
9
- import urllib
10
- import datetime
11
- import json
9
+ #####################################################################################################################################
10
+ # Hide
12
11
  import os
12
+ import sys
13
+ current_dir = os.path.dirname(os.path.abspath(__file__))
14
+ if current_dir in sys.path:
15
+ sys.path.remove(current_dir)
16
+ #####################################################################################################################################
17
+
18
+
19
+ import json
20
+ import datetime
21
+ import urllib
13
22
 
23
+ import pandas as pd
14
24
  import requests
15
25
  from requests.auth import HTTPBasicAuth
16
- import pandas as pd
26
+ from airflow.providers.common.sql.hooks.sql import DbApiHook
27
+ from airflow.exceptions import AirflowNotFoundException
28
+
29
+ import db_analytics_tools as db
17
30
 
18
31
 
19
32
  class AirflowRESTAPIV1:
@@ -403,6 +416,41 @@ class AirflowRESTAPI:
403
416
  return response
404
417
 
405
418
 
419
+ def create_client_from_airflow(airflow_conn_id):
420
+ """
421
+ Creates a database client using connection details from Airflow.
422
+ param airflow_conn_id: Airflow connection ID.
423
+ return: Database client instance.
424
+ """
425
+ # Retrieve connection from Airflow
426
+ try:
427
+ conn_hook = DbApiHook.get_connection(conn_id=airflow_conn_id)
428
+ except AirflowNotFoundException as e:
429
+ raise AirflowNotFoundException(f"Connection ID '{airflow_conn_id}' not found in Airflow: {e}")
430
+ except Exception as e:
431
+ raise Exception(f"Error retrieving connection '{airflow_conn_id}': {e}")
432
+
433
+ # Extract connection parameters
434
+ host = conn_hook.host
435
+ port = conn_hook.port
436
+ schema = conn_hook.schema
437
+ login = conn_hook.login
438
+ password = conn_hook.password
439
+ conn_type = conn_hook.conn_type
440
+
441
+ # Create a database client
442
+ client = db.Client(
443
+ host=host,
444
+ port=port,
445
+ database=schema,
446
+ username=login,
447
+ password=password,
448
+ engine=conn_type
449
+ )
450
+
451
+ return client
452
+
453
+
406
454
  def fetch_data(query, connection_id, output_file, html_file):
407
455
  """
408
456
  Fetches data from a database and saves it to a CSV file and an HTML file.
@@ -0,0 +1,218 @@
1
+ """
2
+ Module for generating HTML email content for ETL job status updates.
3
+ """
4
+
5
+ import smtplib
6
+ from email.mime.multipart import MIMEMultipart
7
+ from email.mime.text import MIMEText
8
+ from email.utils import formataddr, parseaddr
9
+ from typing import Union, List
10
+
11
+
12
+ class MailSender:
13
+ """
14
+ Class to handle secure email sending using SMTP with context management.
15
+ """
16
+ def __init__(
17
+ self,
18
+ email_host: str,
19
+ email_port: int,
20
+ email_user: str,
21
+ email_password: str,
22
+ email_use_tls: bool = True,
23
+ email_use_ssl: bool = False,
24
+ email_from: str = None
25
+ ):
26
+ """
27
+ Initialize the MailSender instance.
28
+
29
+ :param email_host: The SMTP server host address.
30
+ :param email_port: The port number for the SMTP server.
31
+ :param email_user: The username/email for SMTP authentication.
32
+ :param email_password: The password/app password for SMTP authentication.
33
+ :param email_use_tls: Flag to enable Transport Layer Security (TLS).
34
+ :param email_use_ssl: Flag to enable Secure Sockets Layer (SSL).
35
+ :param email_from: Optional custom sender identity string (e.g., "Name <email@domain.com>").
36
+ """
37
+ self.email_host = email_host
38
+ self.email_port = int(email_port)
39
+ self.email_user = email_user
40
+ self.email_password = email_password
41
+ self.email_use_tls = email_use_tls
42
+ self.email_use_ssl = email_use_ssl
43
+
44
+ # Parse friendly name and email address from the custom sender configuration
45
+ self.email_sender_name, self.email_sender_address = parseaddr(email_from)
46
+
47
+ if not self.email_sender_address:
48
+ self.email_sender_address = email_user
49
+
50
+ self.session = None
51
+
52
+ def __enter__(self):
53
+ """
54
+ Establish an authenticated SMTP session when entering the 'with' context block.
55
+
56
+ :return: The initialized MailSender instance.
57
+ """
58
+ print(f"Setting up the mail session {self.email_host}:{self.email_port}...")
59
+
60
+ if self.email_use_ssl:
61
+ self.session = smtplib.SMTP_SSL(self.email_host, self.email_port)
62
+ else:
63
+ self.session = smtplib.SMTP(self.email_host, self.email_port)
64
+
65
+ if self.email_use_tls:
66
+ self.session.starttls()
67
+
68
+ self.session.login(self.email_user, self.email_password)
69
+ print("Mail session launched and authenticated...")
70
+
71
+ return self
72
+
73
+ def _to_list(self, addresses: Union[str, List[str]]) -> List[str]:
74
+ """
75
+ Helper method to normalize single string email addresses or lists into a list.
76
+ """
77
+ if not addresses:
78
+ return []
79
+ if isinstance(addresses, str):
80
+ return [addr.strip() for addr in addresses.split(',') if addr.strip()]
81
+ return [addr.strip() for addr in addresses if addr.strip()]
82
+
83
+ def send_mail(
84
+ self,
85
+ receiver_address: Union[str, List[str]],
86
+ subject: str,
87
+ body_html: str,
88
+ attachments: list = None,
89
+ cc_addresses: Union[str, List[str]] = None,
90
+ bcc_addresses: Union[str, List[str]] = None
91
+ ):
92
+ """
93
+ Construct and send an HTML email message, with optional file attachments.
94
+
95
+ :param receiver_address: The email address(es) of the primary recipient(s). Can be a string or list.
96
+ :param subject: The subject line of the email.
97
+ :param body_html: The HTML-formatted body content of the email.
98
+ :param attachments: Optional list of MIME base instances to append to the email.
99
+ :param cc_addresses: Optional email address(es) to include in the carbon copy (CC).
100
+ :param bcc_addresses: Optional email address(es) to include in the blind carbon copy (BCC).
101
+ """
102
+ if not self.session:
103
+ raise RuntimeError("SMTP session is not active. Use the 'with' block context.")
104
+
105
+ # Normalize all inputs into clean lists of strings
106
+ to_list = self._to_list(receiver_address)
107
+ cc_list = self._to_list(cc_addresses)
108
+ bcc_list = self._to_list(bcc_addresses)
109
+
110
+ if not to_list:
111
+ raise ValueError("At least one primary receiver_address must be provided.")
112
+
113
+ # Setup the MIME multipart message container
114
+ message = MIMEMultipart()
115
+ message['From'] = formataddr((self.email_sender_name, self.email_sender_address))
116
+ message['To'] = ', '.join(to_list)
117
+
118
+ if cc_list:
119
+ message['Cc'] = ', '.join(cc_list)
120
+ if bcc_list:
121
+ message['Bcc'] = ', '.join(bcc_list)
122
+
123
+ message['Subject'] = subject
124
+
125
+ # Process and append any attached files
126
+ if attachments:
127
+ for attachment in attachments:
128
+ message.attach(attachment)
129
+
130
+ # Attach the primary HTML body content
131
+ message.attach(MIMEText(body_html, 'html'))
132
+ text = message.as_string()
133
+
134
+ # SMTP sendmail requires a flat list of all envelope recipients (To + CC + BCC)
135
+ all_recipients = to_list + cc_list + bcc_list
136
+
137
+ self.session.sendmail(self.email_sender_address, all_recipients, text)
138
+ print(f'Mail Sent to {message["To"]} with subject "{subject}".')
139
+
140
+ def __exit__(self, exc_type, exc_val, exc_tb):
141
+ """
142
+ Gracefully terminate the SMTP session when exiting the 'with' context block.
143
+ """
144
+ if self.session:
145
+ self.session.quit()
146
+ print("Mail session closed.")
147
+
148
+
149
+ ##################################################################################################
150
+ ## CONSTANTS
151
+ ##################################################################################################
152
+ MAIL_CONTENT = """
153
+ <!DOCTYPE html>
154
+ <html>
155
+ <head>
156
+ <style>
157
+ table {{
158
+ font-family: Arial, sans-serif;
159
+ border-collapse: collapse;
160
+ width: 100%;
161
+ }}
162
+ th, td {{
163
+ border: 1px solid #dddddd;
164
+ text-align: left;
165
+ padding: 8px;
166
+ }}
167
+ th {{
168
+ background-color: #f2f2f2;
169
+ }}
170
+ tr:nth-child(even) {{
171
+ background-color: #f9f9f9;
172
+ }}
173
+ </style>
174
+ </head>
175
+ <body>
176
+ Bonjour à tous,<br>
177
+ <br>
178
+ L'exécution du job <strong>{etl_name}</strong> est terminée.<br>
179
+ <br>
180
+ Ci-dessous le statut de mise à jour des tables:
181
+ <br>
182
+ <table>
183
+ <tr>
184
+ <th>Check Date</th>
185
+ <th>Table ID</th>
186
+ <th>Table Name</th>
187
+ <th>Last Date</th>
188
+ <th>Load Date</th>
189
+ <th>Status</th>
190
+ <th>Missing Dates</th>
191
+ </tr>
192
+ {html_table}
193
+ </table>
194
+
195
+ <br>
196
+ Bonne réception.<br>
197
+ Big Data & Customer Analytics
198
+ <hr>
199
+ <i>Attention : Ce mail a été généré automatiquement.</i>
200
+ </body>
201
+ </html>
202
+ """
203
+ ##################################################################################################
204
+
205
+ def generate_mail(etl_name, html_table, html_template=MAIL_CONTENT):
206
+ """
207
+ Generate the HTML content for the email.
208
+
209
+ Args:
210
+ html_template (str): The HTML template for the email.
211
+ etl_name (str): The name of the ETL process.
212
+
213
+ Returns:
214
+ str: The generated HTML content.
215
+ """
216
+ return html_template.format(etl_name=etl_name, html_table=open(html_table, "r").read())
217
+
218
+
@@ -12,6 +12,7 @@
12
12
  # Package Imports
13
13
  #####################################################################################################################################
14
14
  import os
15
+ import io
15
16
  import json
16
17
  import time
17
18
  import datetime
@@ -19,6 +20,7 @@ import argparse
19
20
  import subprocess
20
21
  import tempfile
21
22
  import threading
23
+ from textwrap import dedent
22
24
 
23
25
  import streamlit as st
24
26
  import pandas as pd
@@ -29,6 +31,7 @@ import db_analytics_tools as db
29
31
  import db_analytics_tools.integration as dbi
30
32
  from db_analytics_tools.airflow import AirflowRESTAPI
31
33
  from db_analytics_tools.scheduler import CronManager
34
+ from db_analytics_tools.mail import MailSender
32
35
  #####################################################################################################################################
33
36
 
34
37
 
@@ -57,8 +60,10 @@ class DBAnalyticsUI:
57
60
  self.config = config
58
61
  self.app_name = config.get("app_name", "DB Analytics Tools UI")
59
62
  self.app_logo = config.get("app_logo", "")
60
- self.allowed_users = config.get("allowed_users", [])
61
-
63
+ self.allowed_users = config.get("auth_config", {}).get("allowed_users", [])
64
+ self.auth_enabled = config.get("auth_config", {}).get("auth_enabled", False)
65
+ self.mail_enabled = config.get("email_support", {}).get("email_host", None)
66
+
62
67
  #############################################################################################################################
63
68
  # Initialize session state variables if they don't exist
64
69
  #############################################################################################################################
@@ -75,6 +80,51 @@ class DBAnalyticsUI:
75
80
  for key, value in initial_state.items():
76
81
  if key not in st.session_state:
77
82
  st.session_state[key] = value
83
+
84
+ self.setup_mail_sender()
85
+ #################################################################################################################################
86
+
87
+
88
+ #################################################################################################################################
89
+ # Mail Sender Setup
90
+ #################################################################################################################################
91
+ def setup_mail_sender(self):
92
+ """
93
+ Initializes the MailSender instance based on the configuration.
94
+ """
95
+ if self.mail_enabled:
96
+ email_config = self.config.get("email_support", {})
97
+ self.mail_sender = MailSender(
98
+ email_host=email_config.get("email_host"),
99
+ email_port=email_config.get("email_port"),
100
+ email_user=email_config.get("email_user"),
101
+ email_password=email_config.get("email_password"),
102
+ email_use_tls=email_config.get("email_use_tls", True),
103
+ email_use_ssl=email_config.get("email_use_ssl", False),
104
+ email_from=email_config.get("email_from", None)
105
+ )
106
+ self.email_destinator = email_config.get("email_destinator", [])
107
+ else:
108
+ self.mail_sender = None
109
+
110
+ def send_mail_notification(self, subject, message, to_addresses):
111
+ """
112
+ Sends an email notification using the configured MailSender.
113
+
114
+ :param subject: The subject of the email.
115
+ :param message: The body content of the email.
116
+ :param to_addresses: A list of recipient email addresses.
117
+ """
118
+ if not self.mail_enabled or not self.mail_sender or not self.email_destinator:
119
+ print("Email support is not configured. Cannot send email notification.")
120
+ return
121
+
122
+ try:
123
+ with self.mail_sender as sender:
124
+ sender.send_mail(receiver_address=to_addresses, subject=subject, body_html=message)
125
+ print(f"Email notification sent to {to_addresses}")
126
+ except Exception as e:
127
+ print(f"Failed to send email notification: {e}")
78
128
  #################################################################################################################################
79
129
 
80
130
 
@@ -90,8 +140,13 @@ class DBAnalyticsUI:
90
140
  #############################################################################################################################
91
141
  # 1. Authentication
92
142
  #############################################################################################################################
93
- if not self.check_auth() or not st.session_state.auth_status:
94
- return
143
+ if (not self.auth_enabled) or (not self.check_auth()) or (not st.session_state.auth_status):
144
+ if (not self.auth_enabled):
145
+ st.session_state.user = "Guest"
146
+ st.session_state.auth_status = True
147
+ st.warning("Authentication is disabled. Visitor access granted with limited functionality.", width='stretch')
148
+ else:
149
+ return
95
150
 
96
151
  #############################################################################################################################
97
152
  # 2. Sidebar Management
@@ -309,7 +364,7 @@ class DBAnalyticsUI:
309
364
  if is_disabled:
310
365
  if st.button("✅ Enable Job", width='stretch'):
311
366
  cron_manager.enable(comment=selected_job_id)
312
- st.success(f"Job {selected_job} enabled.")
367
+ st.success(f"Job {selected_job} auth_enabled.")
313
368
  else:
314
369
  if st.button("🗑️ Disable Job", type="primary", width='stretch'):
315
370
  cron_manager.disable(comment=selected_job_id)
@@ -617,30 +672,67 @@ class DBAnalyticsUI:
617
672
 
618
673
  duration = datetime.datetime.now() - duration
619
674
  result = f"✅ Pipeline: {selected_pipeline} | Total duration: {duration}"
675
+
676
+ if mail_notification and self.mail_enabled:
677
+ try:
678
+ clean_result = str(result).replace('|', '<br>') if result else "No summary details provided."
679
+ subject = f"DB Analytics Tools - Pipeline Execution Completed: {selected_pipeline}"
680
+ message = dedent(f"""\
681
+ <html>
682
+ <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333333;">
683
+ <p>Hi,</p>
684
+ <div style="margin: 15px 0;">
685
+ {clean_result}
686
+ </div>
687
+ <p>Regards,<br>
688
+ <strong>DB Analytics Tools</strong></p>
689
+ </body>
690
+ </html>
691
+ """)
692
+ self.send_mail_notification(
693
+ subject=subject,
694
+ message=message,
695
+ to_addresses=self.email_destinator,
696
+ )
697
+ except Exception as e:
698
+ print(f"Failed to send email notification: {e}")
620
699
  return result
621
700
 
622
- def extract_sub_module(self, df, table=None):
701
+ def render_export_button(self, df: pd.DataFrame, table: str = None):
623
702
  """
624
- Extracts the sub-module name from a full module path in the 'module' column of the provided DataFrame.
625
- The sub-module is defined as the last segment of the module path after splitting by dots.
703
+ Prepares and displays a Streamlit download button to export the DataFrame as a CSV file.
704
+ Optimized to handle memory efficiently and use proper Streamlit layout properties.
626
705
 
627
- :param df: A pandas DataFrame containing a 'module' column with full module paths.
628
- :param table: The name of the table for which to generate the filename. If None, a default name will be used.
706
+ :param df: A pandas DataFrame containing the data to be exported.
707
+ :param table: Optional table name used to generate a descriptive export filename.
629
708
  """
630
- # Prepare CSV data and filename for download
631
- csv_data = df.to_csv(index=False, sep=';', encoding='utf-8')
632
- file_prefix = table.replace('.', '__') if table else "table"
633
- file_suffix = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
634
- filename_csv = f"{file_prefix}_{file_suffix}_export.csv"
709
+ if df is None or df.empty:
710
+ st.warning("No data available to export.")
711
+ return
712
+
713
+ # Prepare a clean file prefix and suffix for the export file
714
+ file_prefix = table.replace('.', '__') if table else "query_export"
715
+ file_suffix = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
716
+ filename_csv = f"{file_prefix}_{file_suffix}.csv"
717
+
718
+ # Optimize CSV generation in memory to prevent app slowdowns on large DataFrames
719
+ try:
720
+ # We use an in-memory string buffer to avoid duplicating heavy string allocations
721
+ buffer = io.StringIO()
722
+ df.to_csv(buffer, index=False, sep=';', encoding='utf-8')
723
+ csv_data = buffer.getvalue()
724
+ except Exception as e:
725
+ st.error(f"Failed to generate CSV export: {e}")
726
+ return
635
727
 
636
- # Download button
728
+ # Streamlit download button
637
729
  st.download_button(
638
730
  label='Export Table 💾',
639
731
  data=csv_data,
640
732
  file_name=filename_csv,
641
733
  mime="text/csv",
642
- help="Click to download the previewed data as a CSV file",
643
- width='stretch'
734
+ help="Click to download the displayed data as a semi-colon separated CSV file",
735
+ width='content'
644
736
  )
645
737
  #################################################################################################################################
646
738
 
@@ -791,11 +883,11 @@ class DBAnalyticsUI:
791
883
 
792
884
  with col_notify:
793
885
  # Clean layout for notification toggles without redundant checkbox logic
794
- mail_notification = st.checkbox("Activate Email Notification", value=True)
886
+ mail_notification = st.checkbox("Activate Email Notification", value=False, disabled=self.mail_enabled is None or self.email_destinator == [])
795
887
  # Run in background option only for custom mode
796
888
  run_background = st.checkbox(
797
889
  "Run in Background",
798
- value=False,
890
+ value=False,
799
891
  # disabled=(type_etl == "Custom")
800
892
  )
801
893
 
@@ -853,7 +945,7 @@ class DBAnalyticsUI:
853
945
  # 3. PROCESSING & EXECUTION HANDLING
854
946
  # =====================================================================
855
947
  if submit_execution:
856
- if not selected_pipeline or not selected_pipeline.strip():
948
+ if (not selected_pipeline) or (not selected_pipeline.strip()):
857
949
  st.error("Validation Error: Please specify the name of the target function or procedure.")
858
950
  return
859
951
 
@@ -1139,81 +1231,103 @@ class DBAnalyticsUI:
1139
1231
  #############################################################################################################################
1140
1232
  with col2:
1141
1233
  st.subheader("Export data from table")
1142
- tables = client.get_tables(include_all=True, include_size=False)
1234
+
1235
+ try:
1236
+ tables = client.get_tables(include_all=True, include_size=False)
1237
+ available_tables = tables['full_tablename'].unique()
1238
+ except Exception as e:
1239
+ st.error(f"Failed to load database tables: {e}")
1240
+ return
1143
1241
 
1144
1242
  selected_table = st.selectbox(
1145
1243
  "Select the table to export",
1146
- tables['full_tablename'].unique(),
1244
+ available_tables,
1147
1245
  key="export_target_table_selectbox"
1148
1246
  )
1149
1247
 
1150
- # Save keys
1248
+ # Session State Cache identifiers
1151
1249
  cached_df_key = "export_cached_preview_df"
1152
1250
  cached_table_key = "export_cached_preview_table_name"
1153
1251
 
1252
+ # Flush old cache if user switches target tables to prevent exporting incorrect data
1154
1253
  if cached_table_key in st.session_state:
1155
1254
  if st.session_state[cached_table_key] != selected_table:
1156
- # Clean cache if user selects another table to avoid confusion
1157
1255
  st.session_state.pop(cached_df_key, None)
1158
1256
  st.session_state.pop(cached_table_key, None)
1159
1257
 
1160
- # Preview button
1161
- if st.button("Preview table", width='stretch'):
1258
+ # Execute table extraction
1259
+ if st.button("Preview table", width='content'):
1162
1260
  try:
1163
- # Sample the table, then store the data and the name of the active table
1164
- df_preview = client.sample_table(selected_table, 10)
1261
+ sql_query = f"SELECT * FROM {selected_table}"
1262
+ df_preview = client.read_sql(sql_query)
1263
+
1264
+ # Store the complete pulled dataframe and current active table name inside session state
1165
1265
  st.session_state[cached_df_key] = df_preview
1166
1266
  st.session_state[cached_table_key] = selected_table
1167
1267
  except Exception as e:
1168
1268
  st.error(f"Failed to fetch preview: {e}")
1169
-
1170
- # If the cache contains a preview for the active table, display it and the download button
1269
+
1270
+ # Safe rendering from cache (Crucial: prevents crash during download triggers!)
1171
1271
  if cached_df_key in st.session_state:
1172
1272
  df_preview_cached = st.session_state[cached_df_key]
1173
1273
 
1174
- st.dataframe(df_preview_cached, width='stretch', hide_index=True)
1175
-
1176
- self.extract_sub_module(df_preview_cached, selected_table)
1274
+ st.markdown("### Table Preview (First 10 rows)")
1275
+ st.dataframe(df_preview_cached.head(10), width='content', hide_index=True)
1276
+
1277
+ # BUG FIX: Pass the persistent cached DataFrame, not local variables which reset on rerun!
1278
+ self.render_export_button(df_preview_cached, selected_table)
1177
1279
  #############################################################################################################################
1178
1280
 
1179
1281
  def db_page_query_console(self):
1180
1282
  """
1181
- Exploration of tables, sizing, and basic database management.
1283
+ Exploration of tables, query execution, and secure in-memory caching.
1182
1284
  """
1183
1285
  st.header("Query Console 🎮")
1184
- client = st.session_state.current_client
1286
+ client = st.session_state.get("current_client")
1185
1287
 
1288
+ if not client:
1289
+ st.error("No active database client found. Please connect to a database first.")
1290
+ return
1291
+
1186
1292
  st.markdown("---")
1187
1293
  query = st.text_area(
1188
1294
  label="Drop your query (without ; at the end)",
1189
- value="""select *
1190
- from public.example_table
1191
- limit 100""",
1295
+ value="""SELECT *
1296
+ FROM public.example_table
1297
+ LIMIT 100""",
1298
+ height=150
1192
1299
  )
1193
1300
 
1194
- # Initialize session state keys if they don't exist
1195
- if "query_result_df" not in st.session_state:
1196
- st.session_state.query_result_df = None
1301
+ # Initialize session state cache keys safely
1302
+ if "query_result_df_cached" not in st.session_state:
1303
+ st.session_state.query_result_df_cached = None
1197
1304
  if "last_executed_query" not in st.session_state:
1198
1305
  st.session_state.last_executed_query = None
1199
1306
 
1200
- # Trigger execution and store the dataframe in session state
1201
- if st.button("Run query", type="primary", width='stretch'):
1307
+ # Execute query block
1308
+ if st.button("Run query", type="primary", width='content'):
1202
1309
  try:
1203
- # Wrap the query to safely enforce a standard preview limit
1204
- preview_query = f"({query}) AS foo"
1205
- st.session_state.query_result_df = client.sample_table(preview_query, 100)
1310
+ # Execute query and pull results
1311
+ raw_df = client.read_sql(query)
1312
+
1313
+ # Cache the full DataFrame for download, but only preview a subset if needed
1314
+ st.session_state.query_result_df_cached = raw_df
1206
1315
  st.session_state.last_executed_query = query
1316
+
1207
1317
  except Exception as e:
1208
- st.session_state.query_result_df = None
1318
+ st.session_state.query_result_df_cached = None
1209
1319
  st.session_state.last_executed_query = None
1210
- st.error(f"Query execution failed: {e}", width='stretch')
1211
-
1212
- # Persist the rendering outside of the click event block
1213
- if st.session_state.query_result_df is not None:
1214
- st.dataframe(st.session_state.query_result_df, width='stretch', hide_index=True)
1215
- # Display extraction and download layout seamlessly
1216
- self.extract_sub_module(st.session_state.query_result_df)
1320
+ st.error(f"Query execution failed: {e}")
1321
+
1322
+ # Persistent rendering block (Runs on every rerun, surviving the button click event state)
1323
+ cached_df = st.session_state.query_result_df_cached
1324
+ if cached_df is not None:
1325
+ # Show a lightweight preview to maintain blazing-fast browser performance
1326
+ st.subheader("Data Preview (Showing first 100 rows)")
1327
+ st.dataframe(cached_df.head(100), width='content', hide_index=True)
1328
+
1329
+ # Pass the cached dataframe so that the export button works flawlessly on subsequent page clicks
1330
+ self.render_export_button(cached_df, table="custom_query")
1217
1331
  #################################################################################################################################
1218
1332
 
1219
1333
 
@@ -1368,7 +1482,7 @@ import json
1368
1482
  from db_analytics_tools.webapp import DBAnalyticsUI
1369
1483
 
1370
1484
  # Load the configuration
1371
- config_data = {json.dumps(config_data)}
1485
+ config_data = {repr(config_data)}
1372
1486
 
1373
1487
  # Start App
1374
1488
  app = DBAnalyticsUI(config_data)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: db_analytics_tools
3
- Version: 0.2
3
+ Version: 0.2.1
4
4
  Summary: Databases Tools for Data Analytics
5
5
  Home-page: https://joekakone.github.io/db-analytics-tools
6
6
  Download-URL: https://github.com/joekakone/db-analytics-tools
@@ -22,6 +22,7 @@ Requires-Dist: streamlit>=1.32.2
22
22
  Requires-Dist: matplotlib>=3.4.3
23
23
  Requires-Dist: statsmodels>=0.13.5
24
24
  Requires-Dist: python-crontab>=3.1.0
25
+ Requires-Dist: apache-airflow>=3.1.0
25
26
  Dynamic: author
26
27
  Dynamic: author-email
27
28
  Dynamic: description
@@ -6,3 +6,4 @@ streamlit>=1.32.2
6
6
  matplotlib>=3.4.3
7
7
  statsmodels>=0.13.5
8
8
  python-crontab>=3.1.0
9
+ apache-airflow>=3.1.0
@@ -7,7 +7,7 @@ with open("README.md", "r") as f:
7
7
 
8
8
  setup(
9
9
  name="db_analytics_tools",
10
- version="0.2",
10
+ version="0.2.1",
11
11
  url="https://joekakone.github.io/db-analytics-tools",
12
12
  download_url="https://github.com/joekakone/db-analytics-tools",
13
13
  project_urls={
@@ -31,6 +31,7 @@ setup(
31
31
  "matplotlib>=3.4.3",
32
32
  "statsmodels>=0.13.5",
33
33
  "python-crontab>=3.1.0",
34
+ "apache-airflow>=3.1.0",
34
35
  ],
35
36
  python_requires=">=3.9",
36
37
  packages=find_packages(),
@@ -1,76 +0,0 @@
1
- """"
2
- Module for generating HTML email content for ETL job status updates.
3
- """
4
-
5
-
6
-
7
- ##################################################################################################
8
- ## CONSTANTS
9
- ##################################################################################################
10
- MAIL_CONTENT = """
11
- <!DOCTYPE html>
12
- <html>
13
- <head>
14
- <style>
15
- table {{
16
- font-family: Arial, sans-serif;
17
- border-collapse: collapse;
18
- width: 100%;
19
- }}
20
- th, td {{
21
- border: 1px solid #dddddd;
22
- text-align: left;
23
- padding: 8px;
24
- }}
25
- th {{
26
- background-color: #f2f2f2;
27
- }}
28
- tr:nth-child(even) {{
29
- background-color: #f9f9f9;
30
- }}
31
- </style>
32
- </head>
33
- <body>
34
- Bonjour à tous,<br>
35
- <br>
36
- L'exécution du job <strong>{etl_name}</strong> est terminée.<br>
37
- <br>
38
- Ci-dessous le statut de mise à jour des tables:
39
- <br>
40
- <table>
41
- <tr>
42
- <th>Check Date</th>
43
- <th>Table ID</th>
44
- <th>Table Name</th>
45
- <th>Last Date</th>
46
- <th>Load Date</th>
47
- <th>Status</th>
48
- <th>Missing Dates</th>
49
- </tr>
50
- {html_table}
51
- </table>
52
-
53
- <br>
54
- Bonne réception.<br>
55
- Big Data & Customer Analytics
56
- <hr>
57
- <i>Attention : Ce mail a été généré automatiquement.</i>
58
- </body>
59
- </html>
60
- """
61
- ##################################################################################################
62
-
63
- def generate_mail(etl_name, html_table, html_template=MAIL_CONTENT):
64
- """
65
- Generate the HTML content for the email.
66
-
67
- Args:
68
- html_template (str): The HTML template for the email.
69
- etl_name (str): The name of the ETL process.
70
-
71
- Returns:
72
- str: The generated HTML content.
73
- """
74
- return html_template.format(etl_name=etl_name, html_table=open(html_table, "r").read())
75
-
76
-