diff --git a/api/insert_data.py b/api/insert_data.py index 053217a..aef43ef 100644 --- a/api/insert_data.py +++ b/api/insert_data.py @@ -1,7 +1,10 @@ -from flask.globals import request -from database import Database +import datetime + from flask import request + import config +import notifications +from database import Database def insert_data(): @@ -11,6 +14,21 @@ def insert_data(): print("INSERT >> Received valid data: ", r_data) database = Database() + + # TODO: Implement proper check system + # Check for weight differences > 500g + try: + current = database.get_data(datetime.date.strftime(datetime.date.today()-datetime.timedelta(days=5), "%Y-%m-%d"), + datetime.date.strftime(datetime.date.today(), "%Y-%m-%d"))[-1] + if current["weight"] - float(r_data["w"]) > 0.5: + notifications.Feed().push_notification("warning", + "Gewichtsabfall!", + "Das Gewicht ist bei der aktuellen Messung um %skg abgefallen!" + % str(round(float(r_data["w"]) - current["weight"], 2))) + except Exception as e: + print("Error while performing data checks!\n" + "ignoring to still have data inserted\n%s" % e) + database.insert_data(r_data["t"], r_data["w"], r_data["h"]) return "data inserted" diff --git a/app.py b/app.py index 0b95eb6..e340889 100644 --- a/app.py +++ b/app.py @@ -1,10 +1,17 @@ -import config import os +import sys +import threading + +from flask import Flask + +import config from blueprints.api import api +from blueprints.rss import rss from blueprints.views import views -from flask import Flask from database import Database +from notifications import Feed from utils.jsonencoder import CustomJSONEncoder +from telegram import bot as telegram_bot print("waiting until db is ready") os.popen(f"/bin/bash ./docker/wait-for-it.sh {config.MySql.host}:{str(config.MySql.port)}").read() @@ -43,6 +50,9 @@ # Initialize all routes of the REST API app.register_blueprint(api, url_prefix='/api') +# Initialize all routes for the RSS feeds +app.register_blueprint(rss, url_prefix='/rss') + # Append headers @app.after_request def add_header(r): @@ -60,4 +70,17 @@ def add_header(r): # Start the app if __name__ == "__main__": - app.run(host='0.0.0.0', port=config.web_port) + if not config.telegram_bot_token == "": + telegram_bot_thread = threading.Thread(target=telegram_bot.infinity_polling) + telegram_bot_thread.daemon = True + telegram_bot_thread.start() + else: + print(">>> Not starting telegram bot because there is no token") + + try: + Feed().push_notification("admin", "Beelogger startup event", "Beelogger has been started and is now running...") + app.run(host='0.0.0.0', port=config.web_port) + except (KeyboardInterrupt, SystemExit): + print(">>> Stopping BeeLogger...") + database.connection_pool.close() + sys.exit() diff --git a/backup.py b/backup.py index 4a15421..5568596 100644 --- a/backup.py +++ b/backup.py @@ -1,52 +1,61 @@ import os import time -from shutil import copyfile, copytree, make_archive +from shutil import copyfile, make_archive +import notifications from config import FileBackup, MySql -if not os.path.isfile("backup.py"): - print("You need to start this script from the directory it's contained in. Please cd into that folder.") - exit() -print("checking backup directory") +def run_backup(): + if not os.path.isfile("backup.py"): + print("You need to start this script from the directory it's contained in. Please cd into that folder.") + exit() -if not os.path.exists("backup/"): - print("create backup directory") - os.mkdir("backup/") + print("checking backup directory") -print("parsing backup name") -dir_name = time.asctime() -dest = "backup/" + dir_name + "/" -dest = dest.replace(" ", "-") + if not os.path.exists("backup/"): + print("create backup directory") + os.mkdir("backup/") -if os.path.exists(dest): - os.removedirs(dest) + print("parsing backup name") + dir_name = time.asctime() + dest = "backup/" + dir_name + "/" + dest = dest.replace(" ", "-") -os.mkdir(dest) + if os.path.exists(dest): + os.removedirs(dest) -print("downloading MySql database") -os.popen("mysqldump -h %s -u %s -p%s %s > %sdb_backup.sql" % (MySql.host, MySql.user, MySql.password, MySql.db, dest)).readlines() + os.mkdir(dest) -try: - print("copying files") - copyfile("logs/insert.log", dest + "insert.log") - # copytree("stats", dest + "stats/") -except FileNotFoundError: - print("no insert.log file, ignoring") + print("downloading MySql database") + os.popen("mysqldump -h %s -u %s -p%s %s > %sdb_backup.sql" % (MySql.host, MySql.user, MySql.password, MySql.db, dest)).readlines() + + try: + print("copying files") + copyfile("logs/insert.log", dest + "insert.log") + # copytree("stats", dest + "stats/") + except FileNotFoundError: + print("no insert.log file, ignoring") + + print("packing files") + make_archive(dest, "zip", dest) -print("packing files") -make_archive(dest, "zip", dest) + print("cleaning up") + os.popen("rm -r " + dest).readlines() -print("cleaning up") -os.popen("rm -r " + dest).readlines() + print("saving on remote") + if FileBackup.key != "": + cmd = f"scp -o StrictHostKeyChecking=no -i 'secrets/{FileBackup.key}' -P {FileBackup.port} '{dest[:-1]}.zip' '{FileBackup.user}@{FileBackup.host}:{FileBackup.directory}'" + else: + cmd = f"sshpass -p {FileBackup.password} scp -o StrictHostKeyChecking=no -P {FileBackup.port} '{dest[:-1]}.zip' '{FileBackup.user}@{FileBackup.host}:{FileBackup.directory}'" -print("saving on remote") -if FileBackup.key != "": - cmd = f"scp -o StrictHostKeyChecking=no -i 'secrets/{FileBackup.key}' -P {FileBackup.port} '{dest[:-1]}.zip' '{FileBackup.user}@{FileBackup.host}:{FileBackup.directory}'" -else: - cmd = f"sshpass -p {FileBackup.password} scp -o StrictHostKeyChecking=no -P {FileBackup.port} '{dest[:-1]}.zip' '{FileBackup.user}@{FileBackup.host}:{FileBackup.directory}'" + # cmd = "sshpass -p '%s' scp -P %s '%s.zip' '%s@%s:%s'" % (FileBackup.password, FileBackup.port, dest[:-1], FileBackup.user, FileBackup.host, FileBackup.directory) -# cmd = "sshpass -p '%s' scp -P %s '%s.zip' '%s@%s:%s'" % (FileBackup.password, FileBackup.port, dest[:-1], FileBackup.user, FileBackup.host, FileBackup.directory) + print(cmd) + print(os.popen(cmd).read()) -print(cmd) -print(os.popen(cmd).read()) + +try: + run_backup() +except Exception as e: + notifications.Feed().push_notification("admin", "Backup Fehler", "Beim Backupvorgang ist es zu einem Fehler gekommen!\n" + e) diff --git a/blueprints/rss.py b/blueprints/rss.py new file mode 100644 index 0000000..d9133a1 --- /dev/null +++ b/blueprints/rss.py @@ -0,0 +1,47 @@ +from flask import Blueprint, Response, request, jsonify +from flask.templating import render_template +import notifications + +Notifications = notifications.Feed() + +rss = Blueprint("rss", __name__) + +@rss.route("/feeds/", methods=["GET"]) +def show_feeds(): + return render_template('feeds.html') + +@rss.route("//", methods=["GET"]) +def show_feed(feed): + # Get the HTTP request's GET params + args = dict(request.args) + + # Return pretty, HTML-based version of the feed if ?pretty is passed + if "pretty" in args.keys(): + feed_data = Notifications.get_feed(feed, rss_format=False) + return render_template("feed.html", feed_name=feed, records=feed_data) + # Return feed as valid JSON if ?json is passed + elif "json" in args.keys(): + feed_data = jsonify(Notifications.get_feed(feed, rss_format=False)) + return feed_data + # Return feed as valid XML (for instance for RSS readers) + else: + feed_data = Notifications.get_feed(feed, rss_format=True) + return Response(feed_data, mimetype="text/xml") + +@rss.route("///", methods=["GET"]) +def show_article(feed, feed_id): + # Get the HTTP request's GET params + args = dict(request.args) + + feed_data = Notifications.get_feed(feed, rss_format=False) + article = [x for x in feed_data if str(x["id"]) == feed_id][0] + + # Return just the text if ?raw is passed + if "raw" in args.keys(): + return article["text"] + # Return feed as valid JSON if ?json is passed + elif "json" in args.keys(): + return article + # Return pretty, HTML-based version of the feed + else: + return render_template("feed-article.html", feed_name=feed, article=article) diff --git a/database.py b/database.py index 980d1b3..bfb2390 100644 --- a/database.py +++ b/database.py @@ -1,20 +1,27 @@ -import pymysql +import ast +import json + +import mysql.connector.cursor +import mysql.connector.errors +from dbutils.pooled_db import PooledDB import config import time class Database: def __init__(self): - self.mysql_args = { - "host": config.MySql.host, - "port": config.MySql.port, - "user": config.MySql.user, - "password": config.MySql.password, - "db": config.MySql.db, - "charset": "utf8mb4", - "cursorclass": pymysql.cursors.DictCursor - } - - def get_sql_from_file(self): + self.connection_pool = PooledDB(mysql.connector, 5, + host=config.MySql.host, + port=config.MySql.port, + user=config.MySql.user, + password=config.MySql.password, + db=config.MySql.db, + buffered=True + ) + + self.connection_pool.connection().cursor().execute("SET NAMES UTF8") + + @staticmethod + def get_sql_from_file(): """ Reads and parses SQL queries from provided .sql file. """ @@ -37,88 +44,235 @@ def prepare_database(self) -> None: Utilizes SQL from 'database.sql' to create all needed tables automatically. """ - connection = pymysql.connect( - host=self.mysql_args["host"], - port=self.mysql_args["port"], - user=self.mysql_args["user"], - password=self.mysql_args["password"], - charset="utf8mb4" + connection = mysql.connector.connect( + host=config.MySql.host, + port=config.MySql.port, + user=config.MySql.user, + password=config.MySql.password, + buffered=True ) with connection.cursor() as cursor: try: - cursor.execute(f"CREATE DATABASE IF NOT EXISTS {self.mysql_args['db']}") - except pymysql.err.Error as e: + cursor.execute(f"CREATE DATABASE IF NOT EXISTS {config.MySql.db}") + except mysql.connector.Error as e: raise e connection.commit() connection.close() - connection = pymysql.connect(**self.mysql_args) - cursor = connection.cursor() - - # SQL queries as a list - queries = self.get_sql_from_file() - for query in queries: - cursor.execute(query) + with self.connection_pool.connection() as con, con.cursor(dictionary=True) as cursor: + # SQL queries as a list + queries = self.get_sql_from_file() + for query in queries: + cursor.execute(query) - # Commit changes and close connection - connection.commit() - connection.close() - return - + # Commit changes and close connection + con.commit() + con.close() + return def get_data(self, from_date, to_date): - connection = pymysql.connect(**self.mysql_args) - cursor = connection.cursor() - cursor.execute("SELECT * FROM `data` WHERE DATE(`measured`) BETWEEN '%s' AND '%s'" % (from_date, to_date)) - result = cursor.fetchall() - connection.close() - return result + """ + Reads the data records of specified time range. + + Parameters + ---------- + from_date : str + datetime + Specifies starting point of query + to_date : str + datetime + Specifies end point of query + """ + with self.connection_pool.connection() as con, con.cursor(dictionary=True) as cursor: + cursor.execute("SELECT * FROM `data` WHERE DATE(`measured`) BETWEEN '%s' AND '%s'" % (from_date, to_date)) + result = cursor.fetchall() + con.close() + return result def insert_data(self, temperature, weight, humidity): - connection = pymysql.connect(**self.mysql_args) - date = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) - cursor = connection.cursor() + """ + Inserts a new dataset into the database. - cursor.execute("SELECT * FROM data ORDER BY number DESC LIMIT 1") - res = cursor.fetchone() + Parameters + ---------- + temperature : str + float + Current temperature + weight : str + float + Current weight + humidity : str + float + Current humidity + """ + with self.connection_pool.connection() as con, con.cursor(dictionary=True) as cursor: + date = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) - if res is not None and res["weight"] is None: - sql = "UPDATE data SET `temperature` = '%s', `weight` = '%s', `humidity` = '%s' WHERE number = '%s'" % (float(temperature), float(weight) * config.correction[0] - config.real_tare[0], float(humidity), res["number"]) - else: - sql = "INSERT INTO `data` (`number`, `temperature`, `weight`, `humidity`, `measured`) VALUES (0, %s, %s, %s, '%s')" % (float(temperature), float(weight) * config.correction[0] - config.real_tare[0], float(humidity), date) + cursor.execute("SELECT * FROM data ORDER BY number DESC LIMIT 1") + res = cursor.fetchone() - log = open("logs/insert.log", mode="a") - log.write("\n[%s] - %s" % (time.asctime(), sql)) - log.close() + if res is not None and res["weight"] is None: + sql = "UPDATE data SET `temperature` = '%s', `weight` = '%s', `humidity` = '%s' WHERE number = '%s'" % (float(temperature), float(weight) * config.correction[0] - config.real_tare[0], float(humidity), res["number"]) + else: + sql = "INSERT INTO `data` (`number`, `temperature`, `weight`, `humidity`, `measured`) VALUES (0, %s, %s, %s, '%s')" % (float(temperature), float(weight) * config.correction[0] - config.real_tare[0], float(humidity), date) - cursor.execute(sql) - connection.commit() - connection.close() - return + log = open("logs/insert.log", mode="a") + log.write("\n[%s] - %s" % (time.asctime(), sql)) + log.close() + + cursor.execute(sql) + con.commit() + con.close() + return def scales(self, number, weight): - connection = pymysql.connect(**self.mysql_args) - date = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) - cursor = connection.cursor() - cursor.execute("SELECT * FROM data ORDER BY number DESC LIMIT 1") - res = cursor.fetchone() - cursor.execute("DESCRIBE data") - fields = cursor.fetchall() - fields = [str(x["Field"]) for x in fields] - if not number in fields: - return "No column in database for this scale" - if res is not None and res[number] is None: - sql = "UPDATE data SET `%s` = '%s' WHERE number = '%s'" % (number, weight, res['number']) - else: - sql = "INSERT INTO `data` (`%s`, `measured`) VALUES (%s, '%s')" % (number, weight, date) + """ + Inserts a new dataset for a specific scale into the database. + A collumn with the name of "number" must exist! + + Parameters + ---------- + number : str + The unique number of the scale + weight : str + float + Current weight + """ + with self.connection_pool.connection() as con, con.cursor(dictionary=True) as cursor: + date = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + cursor.execute("SELECT * FROM data ORDER BY number DESC LIMIT 1") + res = cursor.fetchone() + cursor.execute("DESCRIBE data") + fields = cursor.fetchall() + fields = [str(x["Field"]) for x in fields] + if number not in fields: + return "No column in database for this scale" + if res is not None and res[number] is None: + sql = "UPDATE data SET `%s` = '%s' WHERE number = '%s'" % (number, weight, res['number']) + else: + sql = "INSERT INTO `data` (`%s`, `measured`) VALUES (%s, '%s')" % (number, weight, date) + + print(sql) + + cursor.execute(str(sql)) + con.commit() + con.close() + + log = open("logs/insert.log", mode="a") + log.write("\n[%s] - %s" % (time.asctime(), sql)) + log.close() + return + + def insert_feed(self, feed_name, data): + """ + Inserts a new feed item into the database. + + Parameters + ---------- + feed_name : str + Name of the feed. + Currently only "data", "admin", or "warning". + data : dict + The data that should get inserted. + """ + + with self.connection_pool.connection() as con, con.cursor(dictionary=True) as cursor: + insert = json.dumps(data, ensure_ascii=False).encode('utf8') + cursor.execute(f"INSERT INTO `notifications` (`feed`, `data`) VALUES (%s, %s)", [feed_name, insert]) + + con.commit() + con.close() + + return True - print(sql) + def get_feed(self, feed_name): + """ + Gets a feed from the database. + + Parameters + ---------- + feed_name : str + Name of the feed. + Currently only "data", "admin", or "warning". + """ + + with self.connection_pool.connection() as con, con.cursor(dictionary=True) as cursor: + cursor.execute(f"SELECT `id`, `data` FROM `notifications` WHERE `feed`='{feed_name}'") + + feed = [] + for item in cursor.fetchall(): + item["data"] = json.loads(item["data"]) + feed.append({ + "time": item["data"]["time"], + "title": bytes(item["data"]["title"], "utf8").decode("utf8"), + "text": bytes(item["data"]["text"], "utf8").decode("utf8"), + "id": item["id"] + }) + + con.close() + + feed.reverse() + + return feed + + def set_telegram_subscription(self, chat_id, feed_name, subscribe): + """ + Changes the feed subscriptions of a telegram chat. + + Parameters + ---------- + chat_id : str + Chat ID of telegram chat. message.chat.id + feed_name : str + Name of the feed to change subscription. "data", "admin", "warning" + subscribe : bool + Specify whether to recieve updates on that feed. + """ + with self.connection_pool.connection() as con, con.cursor(dictionary=True) as cursor: + cursor.execute(f"SELECT * FROM `subscriptions` WHERE `telegram_id`='{chat_id}'") + if cursor.fetchone() is None: + cursor.execute(f"INSERT INTO `subscriptions` (`telegram_id`) VALUES ({chat_id})") + + cursor.execute(f"UPDATE `subscriptions` SET `{feed_name}_feed`='{1 if subscribe else 0}' WHERE `telegram_id`='{chat_id}'") + + con.commit() + con.close() + + return True + + def check_telegram_subscription(self, chat_id, feed_name): + """ + Checks the feed subscriptions of a telegram chat. + + Parameters + ---------- + chat_id : str + Chat ID of telegram chat. message.chat.id + feed_name : str + Name of the feed to check subscription for. "data", "admin", "warning" + """ + with self.connection_pool.connection() as con, con.cursor(dictionary=True) as cursor: + cursor.execute(f"SELECT * FROM `subscriptions` WHERE `telegram_id`='{chat_id}'") + if cursor.fetchone()[f"{feed_name}_feed"] == 1: + con.close() + return True + else: + con.close() + return False + + def get_telegram_subscriptions(self, feed_name): + """ + Gets all chats who have subscribed to a feed. + + Parameters + ---------- + feed_name : str + Name of the feed to check subscription for. "data", "admin", "warning" + """ + with self.connection_pool.connection() as con, con.cursor(dictionary=True) as cursor: + cursor.execute(f"SELECT * FROM `subscriptions` WHERE `{feed_name}_feed`='1'") + res = cursor.fetchall() - cursor.execute(str(sql)) - connection.commit() - connection.close() + con.close() - log = open("logs/insert.log", mode="a") - log.write("\n[%s] - %s" % (time.asctime(), sql)) - log.close() - return \ No newline at end of file + return [x["telegram_id"] for x in res] diff --git a/database.sql b/database.sql index f55f076..e1ed416 100644 --- a/database.sql +++ b/database.sql @@ -1,13 +1,13 @@ --- MySQL dump 10.16 Distrib 10.1.44-MariaDB, for debian-linux-gnu (x86_64) +-- MySQL dump 10.13 Distrib 5.5.62, for Win64 (AMD64) -- -- Host: localhost Database: beelogger -- ------------------------------------------------------ --- Server version 10.1.44-MariaDB-0+deb9u1 +-- Server version 5.5.5-10.3.29-MariaDB-0+deb10u1 /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; /*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; /*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; -/*!40101 SET NAMES utf8mb4 */; +/*!40101 SET NAMES utf8 */; /*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; /*!40103 SET TIME_ZONE='+00:00' */; /*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; @@ -28,17 +28,38 @@ CREATE TABLE IF NOT EXISTS `data` ( `humidity` double DEFAULT NULL, `measured` datetime DEFAULT NULL, PRIMARY KEY (`number`) -) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `notifications` +-- + +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE IF NOT EXISTS `notifications` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `feed` varchar(100) CHARACTER SET utf8mb4 NOT NULL, + `data` text CHARACTER SET utf8mb4 NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8; /*!40101 SET character_set_client = @saved_cs_client */; -- --- Dumping data for table `data` +-- Table structure for table `subscriptions` -- -LOCK TABLES `data` WRITE; -/*!40000 ALTER TABLE `data` DISABLE KEYS */; -/*!40000 ALTER TABLE `data` ENABLE KEYS */; -UNLOCK TABLES; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE IF NOT EXISTS `subscriptions` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `telegram_id` varchar(100) DEFAULT NULL, + `admin_feed` tinyint(1) DEFAULT 0, + `warning_feed` tinyint(1) DEFAULT 0, + `data_feed` tinyint(1) DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4; +/*!40101 SET character_set_client = @saved_cs_client */; -- -- Dumping routines for database 'beelogger' @@ -53,4 +74,4 @@ UNLOCK TABLES; /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2020-05-29 20:22:08 +-- Dump completed on 2021-10-31 0:44:18 diff --git a/docker-compose.yml b/docker-compose.yml index 206c492..b65af71 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,8 @@ services: - "./docker_volumes/logs:/app/logs" - "./docker_volumes/secrets:/app/secrets" environment: + telegram_bot_token: "" + insert_token: "changeme!2" display_token: "changeme!3" diff --git a/docker/docker-requirements.txt b/docker/docker-requirements.txt index cce7f77..1614084 100644 --- a/docker/docker-requirements.txt +++ b/docker/docker-requirements.txt @@ -1,4 +1,7 @@ +mod_wsgi~=4.9.0 Flask~=2.0.2 Werkzeug~=2.0.2 -PyMySQL~=1.0.2 -mod_wsgi~=4.9.0 \ No newline at end of file +rfeed~=1.1.1 +mysql-connector-python~=8.0.27 +DBUtils~=2.0.2 +pyTelegramBotAPI~=4.1.1 \ No newline at end of file diff --git a/example_config.py b/example_config.py index b29309b..48b6b3a 100644 --- a/example_config.py +++ b/example_config.py @@ -13,6 +13,8 @@ def jsonkeys2int(x): if not use_env: + telegram_bot_token = "" + insert_token = "" display_token = "" @@ -21,7 +23,6 @@ def jsonkeys2int(x): real_tare = {0: 0} # tare value before insertion into database class MySql: - host = "" host = "" port = 3306 user = "beelogger" @@ -57,6 +58,8 @@ class Mail: else: + telegram_bot_token = os.environ["telegram_bot_token"] + insert_token = os.environ["insert_token"] display_token = os.environ["display_token"] diff --git a/notifications.py b/notifications.py new file mode 100644 index 0000000..bf987be --- /dev/null +++ b/notifications.py @@ -0,0 +1,102 @@ +import datetime +import time + +import rfeed +from flask import request + +import config +import database +from telegram import bot + +Database = database.Database() + + +class Feed: + def __init__(self): + self.supported_feeds = ["data", "admin", "warning"] + self.feed_descriptions = { + "data": "Jeder Datensatz ist hier verfügbar.", + "admin": "Wichtige Informationen, die nur für Administratoren bestimmt sind.", + "warning": "Allgemeine Warnungen, die das Bienenvolk betreffen." + } + self.feed_names = { + "admin": "BeeLogger Admin Alarm!", + "data": "BeeLogger Datensätze", + "warning": "BeeLogger Bienen Alarm!" + } + + def push_notification(self, feed, title, text): + """ + Creates new item on the warning feed. E.g. temperature critical. + + Parameters + ---------- + feed : str + The name of the feed. "data", "admin", "warning" + title : str + The title of the Item. + text : str + The text to push on the feed. + + Raises + ------ + TypeError + You did not provide a valid feed name. + """ + + if feed not in self.supported_feeds: + raise TypeError("This feed name is unsupported") + + item = { + "time": time.strftime("%Y-%m-%d %H:%M:%S"), + "title": title, + "text": text + } + + Database.insert_feed(feed, item) + + if not config.telegram_bot_token == "": + ########### Telegram Bot ########### + message = f">>>> {self.feed_names[feed]} <<<<\n" \ + f">>> {title} <<<\n" \ + f"{text}\n\n" \ + f"Automatisch generierte Nachricht!" + for chat_id in Database.get_telegram_subscriptions(feed): + bot.send_message(chat_id, message) + + return True + + def get_feed(self, feed_name, rss_format=True): + """ + Gets the data feed. See self.push_data + feed_name : str + Name of the feed to get. "data", "admin", "warning" + rss_format : bool + Specify whether the feed should be returned in a RSS XML format. + """ + if feed_name not in self.supported_feeds: + raise TypeError("This feed name is unsupported") + + feed = Database.get_feed(feed_name) + + if rss_format: + items = [] + for item in feed: + # item = json.loads(item) + items.append(rfeed.Item( + title=item["title"], + description=item["text"], + author="Automatic BeeLogger Notification", + pubDate=datetime.datetime.strptime(item["time"], "%Y-%m-%d %H:%M:%S"), + link=f"{request.host_url}rss/{feed_name}/{item['id']}/", + )) + + return rfeed.Feed( + title=self.feed_names[feed_name], + description=self.feed_descriptions[feed_name], + link=request.url, + language="de-DE", + items=items + ).rss() + + return feed diff --git a/pages/components/sidenav.html b/pages/components/sidenav.html index 0bba586..74c62fc 100644 --- a/pages/components/sidenav.html +++ b/pages/components/sidenav.html @@ -22,6 +22,9 @@ {% endfor %} {% endif %} +
  • +
  • Sonstiges
  • +
  • feedFeeds

  • diff --git a/pages/content/about.html b/pages/content/about.html index bd14909..63049b2 100644 --- a/pages/content/about.html +++ b/pages/content/about.html @@ -10,6 +10,15 @@
    Daten
    Seite zur Verfügung stehen.

    +
    +
    Feeds
    +

    Die komplette Aktivität des Projekts kann über unsere Echtzeit-Feeds eingesehen werden (auch im RSS-Format!)

    + +
    + +
    +
    +
    Credits

    Das Web-Dashboard, das Display und die Daten- und Statistiken-API (Schnittstelle) diff --git a/pages/feed-article.html b/pages/feed-article.html new file mode 100644 index 0000000..9572036 --- /dev/null +++ b/pages/feed-article.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + +{% block custom_header %}{% endblock %} + +{% block content %} +

    +
    + Zurück zum Feed +

    {{ article["title"] }}

    +
    {{ article["time"] }}
    +

    {{ article["text"] }}

    +
    +
    +{% endblock %} + +{% block custom_footer %}{% endblock %} \ No newline at end of file diff --git a/pages/feed.html b/pages/feed.html new file mode 100644 index 0000000..cc93fde --- /dev/null +++ b/pages/feed.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} + +{% block custom_header %}{% endblock %} + +{% block content %} +
    +
    +
    +

    {{ feed_name.upper() }} Feed

    + +
    +
    QR Code zum {{ feed_name.upper() }} RSS Feed:
    + Unable to generate RSS QR code. +
    + + {% for record in records %} +
    +
    + {{ record["title"] }} + {{ record["time"] }} +

    {{ record["text"] }}

    +
    +
    + Ansehen +
    +
    + {% endfor %} +
    +
    +
    +{% endblock %} + +{% block custom_footer %} + +{% endblock %} \ No newline at end of file diff --git a/pages/feeds.html b/pages/feeds.html new file mode 100644 index 0000000..aaaa3bf --- /dev/null +++ b/pages/feeds.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} + +{% block custom_header %}{% endblock %} + +{% block content %} +
    +
    +
    +

    BeeLogger Feeds

    + +

    + BeeLogger verfügt über sogenannte Feeds, in denen automatisierte + System-Benachrichtigungen zu finden sind. +

    + + +
    +
    + Data Feed +

    Feed mit Benachrichtigungen über das Sammeln von Daten.

    +
    +
    + Ansehen +
    +
    + + +
    +
    + Warning Feed +

    Feed mit Warnungen über mögliche Systemabstürze, Störungen, etc.

    +
    +
    + Ansehen +
    +
    + + +
    +
    + Admin Feed +

    Feed mit Benachrichtigungen rund um die Administration von BeeLogger.

    +
    +
    + Ansehen +
    +
    +
    +
    +
    +{% endblock %} + +{% block custom_footer %}{% endblock %} \ No newline at end of file diff --git a/public/css/display.css b/public/css/display.css index 02c41c9..ece8b79 100644 --- a/public/css/display.css +++ b/public/css/display.css @@ -42,6 +42,20 @@ h1 p#date { padding: 0; } +.feeds-frame-wrapper { + width: 100%; + height: 100%; + margin: 0; + padding: 0; +} + +.feeds-frame-wrapper iframe { + display: block; + border: none; + height: 100vh; + width: 100%; +} + #pages-tab, #stundenplan-tab { margin: 0 !important; padding: 0 !important; diff --git a/public/js/app.js b/public/js/app.js index 87a1139..9eafb56 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -233,6 +233,15 @@ function getWeightDelta(data) { return weightDeltaString; } +/** + * When the 'show feeds' button has been clicked on the dashboard + * (not the display!), redirect to the feed page instead of showing + * the iframe. + */ + function showFeeds() { + window.location.href += '/rss/feeds'; +} + /** * Handler function for when the API returns an error. * This function will catch the error and display an error diff --git a/public/js/display.js b/public/js/display.js index c470015..b75e791 100644 --- a/public/js/display.js +++ b/public/js/display.js @@ -146,8 +146,19 @@ function navigatePages(url) { M.Sidenav.getInstance(document.querySelector('#slide-out')).close(); } +/** + * Make 'feeds' iframe on about page visible and navigate it + * to the feeds index page. + */ +function showFeeds() { + let feedsFrame = document.getElementById('feeds-frame'); + feedsFrame.classList.toggle('hide'); + feedsFrame.setAttribute('src', '/rss/feeds'); +} + // Register timers and tabs when document is fully loaded document.addEventListener('DOMContentLoaded', async () => { M.Tabs.init(document.querySelectorAll('.tabs'), {}); + M.Collapsible.init(document.querySelectorAll('.collapsible'), {}); setupTimers(); }); \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 351dd8d..7336811 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,7 @@ pip~=21.3 Flask~=2.0.2 Werkzeug~=2.0.2 numpy~=1.21.3 -PyMySQL~=1.0.2 -mod_wsgi~=4.9.0 \ No newline at end of file +rfeed~=1.1.1 +mysql-connector-python~=8.0.27 +DBUtils~=2.0.2 +pyTelegramBotAPI~=4.1.1 diff --git a/telegram.py b/telegram.py new file mode 100644 index 0000000..6c3dea1 --- /dev/null +++ b/telegram.py @@ -0,0 +1,47 @@ +import telebot + +import config +import database + +bot = telebot.TeleBot(config.telegram_bot_token, parse_mode=None) + +Database = database.Database() + +@bot.message_handler(commands=['start']) +def send_welcome(message): + print(message.chat.id) + bot.send_message(message.chat.id, "Hi!\nIch bin der BeeLogger Bot, der dir automatisch Informationen zum Bienenschwarm schickt!\n\n" + "Starte mit:\n" + "/aboniere_warnungen um Warnungen zum Bienenvolk zu erhalten.\n" + "/deaboniere_warnungen um Warnungen abzubestellen.") + +@bot.message_handler(commands=['aboniere_warnungen']) +def sub_warnings(message): + Database.set_telegram_subscription(message.chat.id, "warning", True) + bot.reply_to(message, "Du erhälst nun Warnungen zum Bienenvolk!") +@bot.message_handler(commands=['deaboniere_warnungen']) +def unsub_warnings(message): + Database.set_telegram_subscription(message.chat.id, "warning", False) + bot.reply_to(message, "Du erhälst nun keine Warnungen mehr!") + +@bot.message_handler(commands=['aboniere_admin']) +def sub_admin(message): + Database.set_telegram_subscription(message.chat.id, "admin", True) + bot.reply_to(message, "Du erhälst nun Admin Nachrichten der Software!") +@bot.message_handler(commands=['deaboniere_admin']) +def unsub_admin(message): + Database.set_telegram_subscription(message.chat.id, "admin", False) + bot.reply_to(message, "Du erhälst nun keine Admin Nachrichten mehr!") + +@bot.message_handler(commands=['aboniere_data']) +def sub_data(message): + Database.set_telegram_subscription(message.chat.id, "data", True) + bot.reply_to(message, "Du erhälst nun Datensätze via Telegram!") +@bot.message_handler(commands=['deaboniere_data']) +def unsub_data(message): + Database.set_telegram_subscription(message.chat.id, "data", False) + bot.reply_to(message, "Du erhälst nun keine Datensätze mehr via Telegram./st") + + +if __name__ == "__main__": + bot.infinity_polling()