diff --git a/.env_example b/.env_example index 138b591..96ee72d 100644 --- a/.env_example +++ b/.env_example @@ -15,4 +15,9 @@ AGE_PASS=pass AGE_HOST=localhost AGE_PORT=5432 AGE_DB=university -AGE_GRAPH_NAME=movie_graph \ No newline at end of file +AGE_GRAPH_NAME=movie_graph + +INFLUX_ACTIVE=false +INFLUXDB3_HOST=http://localhost:8181 +INFLUXDB3_AUTH_TOKEN=your_token +INFLUXDB3_DATABASE=sensors \ No newline at end of file diff --git a/README.md b/README.md index 8a20ad9..fc03a23 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ ### Пример приложения для работы с БД -Небольшое десктоп-приложение на PyQt5 для работы с PostgreSQL, Neo4j и Apache AGE. +Небольшое десктоп-приложение на PyQt5 для работы с PostgreSQL, Neo4j, Apache AGE и InfluxDB 3 Core. ### Стек - PyQt5 — графический интерфейс - PostgreSQL (psycopg2) — реляционная БД для пользователей -- Neo4j — графовая БД для фильмов -- Apache AGE — графовая модель поверх PostgreSQL для фильмов -- python-dotenv — загрузка параметров подключения из файла `.env` +- Neo4j — графовая БД +- Apache AGE — графовая БД в рамках PostgreSQL +- InfluxDB 3 Core — БД временных рядов ### Скриншоты @@ -26,6 +26,7 @@ - Установленный PostgreSQL и доступ к серверу БД - Установленный Neo4j и загруженный обучающий граф фильмов (Movie Graph example) - Для Apache AGE: установленное расширение `age` в PostgreSQL и доступ к базе, где оно включено +- Для InfluxDB 3 Core: запущенный сервер, заранее созданная база и токен с правами на запись и чтение ### Настройка окружения @@ -53,6 +54,11 @@ AGE_HOST=localhost AGE_PORT=5432 AGE_DB=university AGE_GRAPH_NAME=movie_graph + +INFLUX_ACTIVE=false # включить InfluxDB 3 Core, установив true +INFLUXDB3_HOST=http://localhost:8181 +INFLUXDB3_AUTH_TOKEN=your_token +INFLUXDB3_DATABASE=sensors ``` 2. Создайте и активируйте виртуальное окружение: @@ -87,4 +93,4 @@ pip install -r requirements.txt py main.py ``` -Приложение подключится к PostgreSQL, создаст БД/таблицу и тестовые данные (если их еще нет), выполнит запрос к Neo4j (если активно) и/или к Apache AGE (если активно), а затем отобразит результаты в отдельных окнах. \ No newline at end of file +Приложение подключится к PostgreSQL, создаст БД/таблицу и тестовые данные (если их еще нет), выполнит запрос к Neo4j, Apache AGE и/или InfluxDB 3 Core (если они активны), а затем отобразит результаты в отдельных окнах. \ No newline at end of file diff --git a/main.py b/main.py index 4d217f5..7c0b5e3 100644 --- a/main.py +++ b/main.py @@ -3,6 +3,7 @@ import sys import psycopg2 from dotenv import load_dotenv +from influxdb_client_3 import InfluxDBClient3 from neo4j import GraphDatabase from psycopg2 import sql from psycopg2.errors import DuplicateDatabase @@ -30,6 +31,11 @@ AGE_PORT = os.getenv("AGE_PORT", POSTGRES_PORT) AGE_DB = os.getenv("AGE_DB", POSTGRES_DB) AGE_GRAPH_NAME = os.getenv("AGE_GRAPH_NAME", "movie_graph") +INFLUX_ACTIVE = os.getenv("INFLUX_ACTIVE", "false").lower() == "true" +INFLUXDB3_HOST = os.getenv("INFLUXDB3_HOST", "http://localhost:8181") +INFLUXDB3_AUTH_TOKEN = os.getenv("INFLUXDB3_AUTH_TOKEN") +INFLUXDB3_DATABASE = os.getenv("INFLUXDB3_DATABASE", "sensors") + USER_SEED_DATA = [ (1, "Ivan", 15), (2, "Igor", 22), @@ -154,6 +160,48 @@ def select_age_movies(cur): return [str(row[0]).strip('"') for row in cur.fetchall()] +def connect_influx(): + return InfluxDBClient3( + host=INFLUXDB3_HOST, + token=INFLUXDB3_AUTH_TOKEN, + database=INFLUXDB3_DATABASE, + ) + + +def insert_influx_sensor_data(client): + client.write( + """temperature,location=room1,sensor_id=s01 value=22.5 +temperature,location=room2,sensor_id=s02 value=24.1 +temperature,location=room1,sensor_id=s01 value=23.0 +humidity,location=room1,sensor_id=s01 value=55.2 +humidity,location=room2,sensor_id=s02 value=60.8""" + ) + + +def select_influx_sensor_data(client): + table = client.query( + query=""" +SELECT measurement, location, sensor_id, value, CAST(time AS STRING) AS time +FROM ( + SELECT 'temperature' AS measurement, location, sensor_id, value, time FROM temperature + UNION ALL + SELECT 'humidity' AS measurement, location, sensor_id, value, time FROM humidity +) +ORDER BY time, measurement +""", + language="sql", + mode="all", + ) + rows = table.to_pylist() + + if not rows: + return ["measurement", "location", "sensor_id", "value", "time"], [] + + headers = list(rows[0].keys()) + values = [tuple(row.get(header) for header in headers) for row in rows] + return headers, values + + def load_users(): admin_conn = connect_postgres("postgres") try: @@ -180,7 +228,7 @@ class MainWindow(QMainWindow): central_widget = QWidget(self) self.setCentralWidget(central_widget) - grid_layout = QGridLayout(self) + grid_layout = QGridLayout() central_widget.setLayout(grid_layout) self.table = QTableWidget(self) @@ -206,40 +254,49 @@ class MainWindow(QMainWindow): self.table.resizeColumnsToContents() -class GraphWindow(QWidget): - def __init__(self, title, parent_window): +class DataWindow(QWidget): + def __init__(self, title, headers, parent_window, window_index=0): super().__init__() self.setWindowTitle(title) + self.parent_window = parent_window + self.window_index = window_index grid_layout = QGridLayout(self) self.setLayout(grid_layout) self.table = QTableWidget(self) - self.table.setColumnCount(1) + self.table.setColumnCount(len(headers)) self.table.setRowCount(0) - self.table.setHorizontalHeaderLabels(["Info"]) - self.table.horizontalHeaderItem(0).setToolTip("Info") - self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignHCenter) + self.table.setHorizontalHeaderLabels(headers) + for index, header in enumerate(headers): + self.table.horizontalHeaderItem(index).setToolTip(header) + self.table.horizontalHeaderItem(index).setTextAlignment(Qt.AlignHCenter) grid_layout.addWidget(self.table, 0, 0) - self.position_relative_to_parent(parent_window) + self.position_relative_to_parent() - def position_relative_to_parent(self, parent_window): - parent_frame = parent_window.frameGeometry() - self.move(parent_frame.x() + parent_frame.width() + 5, parent_frame.y()) + def position_relative_to_parent(self): + parent = self.parent_window.frameGeometry() + screen = QApplication.primaryScreen().availableGeometry() + self.move( + max(screen.x(), min(parent.right() + 6 + self.window_index * (self.width() + 5), screen.right() - self.width() - 10)), + max(screen.y(), min(parent.y(), screen.bottom() - self.height() - 10)), + ) def load_data(self, rows): self.table.setRowCount(len(rows)) - for row_number, item in enumerate(rows): - self.table.setItem(row_number, 0, QTableWidgetItem(str(item))) + for row_number, row in enumerate(rows): + values = row if isinstance(row, (list, tuple)) else [row] + for column_number, value in enumerate(values): + self.table.setItem(row_number, column_number, QTableWidgetItem(str(value))) self.table.resizeColumnsToContents() self.table.resizeRowsToContents() margins = self.layout().contentsMargins() width = ( self.table.verticalHeader().width() - + self.table.columnWidth(0) + + sum(self.table.columnWidth(column) for column in range(self.table.columnCount())) + self.table.frameWidth() * 2 + margins.left() + margins.right() @@ -254,7 +311,11 @@ class GraphWindow(QWidget): + 24 ) - self.resize(min(max(width, 220), 700), min(max(height, 120), 500)) + screen_geometry = QApplication.primaryScreen().availableGeometry() + max_width = max(screen_geometry.width() - 40, 220) + max_height = max(screen_geometry.height() - 40, 120) + self.resize(min(max(width, 220), max_width), min(max(height, 120), max_height)) + self.position_relative_to_parent() if __name__ == "__main__": @@ -269,8 +330,8 @@ if __name__ == "__main__": if NEO_ACTIVE: try: - neo_window = GraphWindow("neo4j", main_window) - neo_window.load_data(fetch_neo_movies()) + neo_window = DataWindow("neo4j", ["Info"], main_window, len(graph_windows)) + neo_window.load_data([(title,) for title in fetch_neo_movies()]) neo_window.show() graph_windows.append(neo_window) except Exception as error: @@ -291,11 +352,24 @@ if __name__ == "__main__": create_age_movies(cur) age_movies = select_age_movies(cur) - age_window = GraphWindow("apache age", main_window) - age_window.load_data(age_movies) + age_window = DataWindow("apache age", ["Info"], main_window, len(graph_windows)) + age_window.load_data([(title,) for title in age_movies]) age_window.show() graph_windows.append(age_window) except Exception as error: print(f"Apache AGE load failed: {error}") + if INFLUX_ACTIVE: + try: + with connect_influx() as influx_client: + insert_influx_sensor_data(influx_client) + influx_headers, influx_rows = select_influx_sensor_data(influx_client) + + influx_window = DataWindow("influxdb 3 core", influx_headers, main_window, len(graph_windows)) + influx_window.load_data(influx_rows) + influx_window.show() + graph_windows.append(influx_window) + except Exception as error: + print(f"InfluxDB 3 Core load failed: {error}") + sys.exit(app.exec()) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 84bbb8d..45dc001 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ psycopg2-binary neo4j PyQt5 python-dotenv +influxdb3-python