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.
- {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/PKG-INFO +2 -1
- {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools/__init__.py +1 -1
- {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools/airflow.py +52 -4
- db_analytics_tools-0.2.1/db_analytics_tools/mail.py +218 -0
- {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools/webapp.py +170 -56
- {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools.egg-info/PKG-INFO +2 -1
- {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools.egg-info/requires.txt +1 -0
- {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/setup.py +2 -1
- db_analytics_tools-0.2/db_analytics_tools/mail.py +0 -76
- {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/LICENSE +0 -0
- {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/README.md +0 -0
- {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools/analytics.py +0 -0
- {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools/cli.py +0 -0
- {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools/integration.py +0 -0
- {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools/learning.py +0 -0
- {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools/plotting.py +0 -0
- {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools/scheduler.py +0 -0
- {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools/utils.py +0 -0
- {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools/webapp_v1.py +0 -0
- {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools.egg-info/SOURCES.txt +0 -0
- {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools.egg-info/dependency_links.txt +0 -0
- {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools.egg-info/entry_points.txt +0 -0
- {db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools.egg-info/top_level.txt +0 -0
- {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
|
|
@@ -6,14 +6,27 @@
|
|
|
6
6
|
This module provides a class for interacting with the Apache Airflow REST API.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
|
701
|
+
def render_export_button(self, df: pd.DataFrame, table: str = None):
|
|
623
702
|
"""
|
|
624
|
-
|
|
625
|
-
|
|
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
|
|
628
|
-
:param table:
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
#
|
|
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
|
|
643
|
-
width='
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
1244
|
+
available_tables,
|
|
1147
1245
|
key="export_target_table_selectbox"
|
|
1148
1246
|
)
|
|
1149
1247
|
|
|
1150
|
-
#
|
|
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
|
-
#
|
|
1161
|
-
if st.button("Preview table", width='
|
|
1258
|
+
# Execute table extraction
|
|
1259
|
+
if st.button("Preview table", width='content'):
|
|
1162
1260
|
try:
|
|
1163
|
-
|
|
1164
|
-
df_preview = client.
|
|
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
|
-
#
|
|
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.
|
|
1175
|
-
|
|
1176
|
-
|
|
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,
|
|
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="""
|
|
1190
|
-
|
|
1191
|
-
|
|
1295
|
+
value="""SELECT *
|
|
1296
|
+
FROM public.example_table
|
|
1297
|
+
LIMIT 100""",
|
|
1298
|
+
height=150
|
|
1192
1299
|
)
|
|
1193
1300
|
|
|
1194
|
-
# Initialize session state keys
|
|
1195
|
-
if "
|
|
1196
|
-
st.session_state.
|
|
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
|
-
#
|
|
1201
|
-
if st.button("Run query", type="primary", width='
|
|
1307
|
+
# Execute query block
|
|
1308
|
+
if st.button("Run query", type="primary", width='content'):
|
|
1202
1309
|
try:
|
|
1203
|
-
#
|
|
1204
|
-
|
|
1205
|
-
|
|
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.
|
|
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}"
|
|
1211
|
-
|
|
1212
|
-
#
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
#
|
|
1216
|
-
|
|
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 = {
|
|
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
|
|
@@ -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
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{db_analytics_tools-0.2 → db_analytics_tools-0.2.1}/db_analytics_tools.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|