From 0dc3aa77846ae6ef76e245bd10024caff0920840 Mon Sep 17 00:00:00 2001
From: David Trattnig <david.trattnig@o94.at>
Date: Thu, 6 May 2021 15:03:49 +0200
Subject: [PATCH] PostgreSQL support. #73

---
 config/sample-development.engine.ini   | 13 +++----
 config/sample-docker.engine.ini        | 13 +++----
 config/sample-production.engine.ini    | 13 +++----
 contrib/mariadb-requirements.txt       |  1 +
 contrib/postgresql-create-database.sql |  4 +++
 contrib/postgresql-requirements.txt    |  1 +
 docs/bare-metal-installation.md        | 49 +++++++++++++-------------
 scripts/setup-db-mariadb.sh            | 37 -------------------
 scripts/setup-db.sh                    | 37 -------------------
 src/base/config.py                     | 16 +++++----
 src/scheduling/models.py               | 31 +++++++++-------
 11 files changed, 81 insertions(+), 134 deletions(-)
 create mode 100644 contrib/mariadb-requirements.txt
 create mode 100644 contrib/postgresql-create-database.sql
 create mode 100644 contrib/postgresql-requirements.txt
 delete mode 100755 scripts/setup-db-mariadb.sh
 delete mode 100755 scripts/setup-db.sh

diff --git a/config/sample-development.engine.ini b/config/sample-development.engine.ini
index 79954cc7..2217e735 100644
--- a/config/sample-development.engine.ini
+++ b/config/sample-development.engine.ini
@@ -66,6 +66,13 @@ api_engine_store_clock="http://localhost:8008/api/v1/clock"
 api_engine_store_health="http://localhost:8008/api/v1/source/health/${ENGINE_NUMBER}"
 
 [scheduler]
+# Database settings: Use 'postgresql' or 'mysql'
+db_type="postgresql"
+db_name="aura_engine"
+db_user="aura_engine"
+db_pass="---SECRET--PASSWORD---"
+db_host="localhost"
+db_charset="utf8"
 # Base path as seen by "engine-core", not accessed by "engine"; this is required to construct the absolute audio file path (check "Audio Store" in the docs)
 # Either provide an absolute base path or a relative one starting in the `engine-core/src` directory. In case of `engine-core` running in docker use `/var/audio/source`
 audio_source_folder="audio/source"
@@ -76,12 +83,6 @@ audio_playlist_folder="audio/playlist"
 engine_latency_offset=0.5
 # How often should the calendar be fetched in seconds. This determines the time of the last changes applied, before a specific show aired
 fetching_frequency=30
-# The schedule is fetched on every "fetching_frequency" cycle and stored in a local database
-db_user="aura"
-db_name="aura_engine"
-db_pass="---SECRET--PASSWORD---"
-db_host="localhost"
-db_charset="utf8"
 # The scheduling window defines when the entries of each timeslot are queued for play-out. The windows start at (timeslot.start - window_start) seconds
 # and ends at (timeslot.end - window.end) seconds. Its also worth noting, that timeslots can only be deleted before the start of the window.
 scheduling_window_start=60
diff --git a/config/sample-docker.engine.ini b/config/sample-docker.engine.ini
index a681b3c4..d954d091 100644
--- a/config/sample-docker.engine.ini
+++ b/config/sample-docker.engine.ini
@@ -66,6 +66,13 @@ api_engine_store_clock="http://127.0.0.1:8008/api/v1/clock"
 api_engine_store_health="http://127.0.0.1:8008/api/v1/source/health/${ENGINE_NUMBER}"
 
 [scheduler]
+# Database settings: Use 'postgresql' or 'mysql'
+db_type="postgresql"
+db_name="aura_engine"
+db_user="aura_engine"
+db_pass="---SECRET--PASSWORD---"
+db_host="127.0.0.1"
+db_charset="utf8"
 # Base path as seen by "engine-core", not accessed by "engine"; this is required to construct the absolute audio file path (check "Audio Store" in the docs)
 # Either provide an absolute base path or a relative one starting in the `engine-core/src` directory. In case of `engine-core` running in docker use `/var/audio/source`
 audio_source_folder="/var/audio/source"
@@ -76,12 +83,6 @@ audio_playlist_folder="/var/audio/playlist"
 engine_latency_offset=0.5
 # How often should the calendar be fetched in seconds. This determines the time of the last changes applied, before a specific show aired
 fetching_frequency=300
-# The schedule is fetched on every "fetching_frequency" cycle and stored in a local database
-db_user="aura"
-db_name="aura_engine"
-db_pass="---SECRET--PASSWORD---"
-db_host="localhost"
-db_charset="utf8"
 # The scheduling window defines when the entries of each timeslot are queued for play-out. The windows start at (timeslot.start - window_start) seconds
 # and ends at (timeslot.end - window.end) seconds. Its also worth noting, that timeslots can only be deleted before the start of the window.
 scheduling_window_start=60
diff --git a/config/sample-production.engine.ini b/config/sample-production.engine.ini
index 052792b1..723b58c8 100644
--- a/config/sample-production.engine.ini
+++ b/config/sample-production.engine.ini
@@ -66,6 +66,13 @@ api_engine_store_clock="http://localhost:8008/api/v1/clock"
 api_engine_store_health="http://localhost:8008/api/v1/source/health/${ENGINE_NUMBER}"
 
 [scheduler]
+# Database settings: Use 'postgresql' or 'mysql'
+db_type="postgresql"
+db_name="aura_engine"
+db_user="aura_engine"
+db_pass="---SECRET--PASSWORD---"
+db_host="localhost"
+db_charset="utf8"
 # Base path as seen by "engine-core", not accessed by "engine"; this is required to construct the absolute audio file path (check "Audio Store" in the docs)
 # Either provide an absolute base path or a relative one starting in the `engine-core/src` directory. In case of `engine-core` running in docker use `/var/audio/source`
 audio_source_folder="audio/source"
@@ -76,12 +83,6 @@ audio_playlist_folder="audio/playlist"
 engine_latency_offset=0.5
 # How often should the calendar be fetched in seconds. This determines the time of the last changes applied, before a specific show aired
 fetching_frequency=300
-# The schedule is fetched on every "fetching_frequency" cycle and stored in a local database
-db_user="aura"
-db_name="aura_engine"
-db_pass="---SECRET--PASSWORD---"
-db_host="localhost"
-db_charset="utf8"
 # The scheduling window defines when the entries of each timeslot are queued for play-out. The windows start at (timeslot.start - window_start) seconds
 # and ends at (timeslot.end - window.end) seconds. Its also worth noting, that timeslots can only be deleted before the start of the window.
 scheduling_window_start=60
diff --git a/contrib/mariadb-requirements.txt b/contrib/mariadb-requirements.txt
new file mode 100644
index 00000000..ccd50492
--- /dev/null
+++ b/contrib/mariadb-requirements.txt
@@ -0,0 +1 @@
+mysqlclient==1.3.12
\ No newline at end of file
diff --git a/contrib/postgresql-create-database.sql b/contrib/postgresql-create-database.sql
new file mode 100644
index 00000000..d0732d14
--- /dev/null
+++ b/contrib/postgresql-create-database.sql
@@ -0,0 +1,4 @@
+\c postgres
+create database aura_engine;
+create user aura_engine with encrypted password '1234';
+grant all privileges on database aura_engine to aura_engine;
diff --git a/contrib/postgresql-requirements.txt b/contrib/postgresql-requirements.txt
new file mode 100644
index 00000000..4ae932dc
--- /dev/null
+++ b/contrib/postgresql-requirements.txt
@@ -0,0 +1 @@
+psycopg2-binary==2.8.6
\ No newline at end of file
diff --git a/docs/bare-metal-installation.md b/docs/bare-metal-installation.md
index 33a89b8e..3f4e0ec2 100644
--- a/docs/bare-metal-installation.md
+++ b/docs/bare-metal-installation.md
@@ -4,8 +4,8 @@
 
 - [Install for Development](#install-for-development)
     - [Prerequisites](#prerequisites)
-- [Setting up the database](#setting-up-the-database)
     - [Preparation](#preparation)
+        - [Setting up the database](#setting-up-the-database)
     - [Configuration](#configuration)
     - [Running Engine](#running-engine)
     - [Daemonized Engine](#daemonized-engine)
@@ -23,30 +23,9 @@ Aura Engine runs on any modern Debian-based OS. It requires at least
 - [`Python 3.8+`](https://www.python.org/downloads/release/python-380/)
 - [`pip`](https://pip.pypa.io/en/stable/)
 - [`git`](https://git-scm.com/)
-- ['virtualenv'](https://pypi.org/project/virtualenv/) (development only)
-- DBMS server/client libraries for MariaDB or PostgreSQL (see below)
+- ['virtualenv'](https://pypi.org/project/virtualenv/)
+- [PostgreSQL 13+](https://www.postgresql.org/) or [MariaDB 10+](https://mariadb.org/)
 
-# Setting up the database
-
-Depending on the DBMS you are planning to use you'll need to have the relevant server/client libaries to be present.
-
-**MariaDB**
-
-```shell
-sudo apt install \
-    python3.8-dev \
-    default-libmysqlclient-dev \
-    mariadb-server \
-    libmariadbclient-dev
-```
-
-The following installation script sets up the initial databases and users.
-
-```bash
-  bash scripts/setup-db.sh
-```
-
-As soon as this is completed, carefully check if any error occured. In case your database has been setup automatically, note the relevant credentials for later use in your `engine.ini` configuration.
 
 **Setting up the project structure**
 
@@ -94,6 +73,28 @@ cp config/sample.development.engine.ini config/engine.ini
 cp config/sample.production.engine.ini config/engine.ini
 ```
 
+### Setting up the database
+
+The primary database supported by AURA is PostgreSQL.
+
+```bash
+# Additional Python packages for PostgreSQL
+pip3 install -r contrib/postgresql-requirements.txt
+# Create database and user (change password in script)
+sudo -u postgres psql -f contrib/postgresql-create-database.sql
+```
+
+Alternatively you can also use MariaDB:
+
+```bash
+# Additional Python packages for MariaDB
+pip3 install -r contrib/mariadb-requirements.txt
+# Create database and user (change password in script)
+sudo mysql -u root -p < contrib/mariadb-database.sql
+```
+
+You might want to change the password for the database user created by the relevant script.
+
 ## Configuration
 
 In your development environment edit following file to configure the engine:
diff --git a/scripts/setup-db-mariadb.sh b/scripts/setup-db-mariadb.sh
deleted file mode 100755
index a6d7c4d8..00000000
--- a/scripts/setup-db-mariadb.sh
+++ /dev/null
@@ -1,37 +0,0 @@
-#!/bin/bash
-
-# Check if databases are already set-up
-if test -f "$LOCKFILE_DB"; then
-  echo "Aura Engine Databases are already existing! Skipping..."
-else
-  # Create random password
-  PASS_ENGINE="$(openssl rand -base64 24)"
-
-  # Create databases and users
-  echo "--- SETTING UP DATABASE AND USERS ---"
-  echo "Please enter the MySQL/MariaDB root password!"
-  stty -echo
-  printf "Password: "
-  read rootpasswd
-  stty echo
-  printf "\n"
-  echo "---"
-  
-  echo "Creating database for Aura Engine..."
-  mysql -uroot -p${rootpasswd} -e "CREATE DATABASE aura_engine CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
-  mysql -uroot -p${rootpasswd} -e "CREATE USER 'aura'@'localhost' IDENTIFIED BY '${PASS_ENGINE}';"
-  mysql -uroot -p${rootpasswd} -e "GRANT ALL PRIVILEGES ON aura_engine.* TO 'aura'@'localhost';"
-  mysql -uroot -p${rootpasswd} -e "FLUSH PRIVILEGES;"
-  echo "Done."
-    
-  echo
-  echo
-  echo "Please note your database credentials for the next configuration steps:"
-  echo "-----------------------------------------------------------------------"
-  echo " Database: 'aura_engine'"
-  echo " User:     'aura'"
-  echo " Password: '${PASS_ENGINE}'"
-  echo "-----------------------------------------------------------------------"
-  echo
-
-fi
\ No newline at end of file
diff --git a/scripts/setup-db.sh b/scripts/setup-db.sh
deleted file mode 100755
index 6ee28f84..00000000
--- a/scripts/setup-db.sh
+++ /dev/null
@@ -1,37 +0,0 @@
-#!/bin/bash
-
-#
-# Setup Database
-#
-
-# Set LOCK file location
-LOCKFILE_DB=.engine.install-db.lock
-
-# Check if databases are already set-up
-if test -f "$LOCKFILE_DB"; then
-  echo "Aura Engine Databases are already existing! Skipping..."
-else
-    echo "Setting up database ..."
-    echo 
-    echo "Which database system do you want to use? (Press '1' or '2')"
-    echo "  [1] MariaDB"
-    echo "  [2] Other / Manually"
-    echo
-
-    while true; do
-    read -rsn1 input
-
-    if [ "$input" = "1" ]; then
-        echo "Creating DB for MariaDB ..."
-        bash scripts/setup-db-mariadb.sh
-        break
-    fi
-    if [ "$input" = "2" ]; then
-        echo "Manual database setup selected."
-        break
-    fi
-    done
-
-    # Create lockfile to avoid accidential re-creation of the database
-    touch $LOCKFILE_DB
-fi
diff --git a/src/base/config.py b/src/base/config.py
index 1d4679ba..52056e65 100644
--- a/src/base/config.py
+++ b/src/base/config.py
@@ -103,18 +103,22 @@ class AuraConfig:
         return self.__dict__[key]
 
 
-
     def get_database_uri(self):
         """
         Retrieves the database connection string.
         """
-        db_name = self.get("db_name")
-        db_user = self.get("db_user")
+        db_name = str(self.get("db_name"))
+        db_user = str(self.get("db_user"))
         db_pass = str(self.get("db_pass"))
-        db_host = self.get("db_host")
+        db_host = str(self.get("db_host"))
+        db_type = str(self.get("db_type"))
         db_charset = self.get("db_charset", "utf8")
-        return "mysql://" + db_user + ":" + db_pass + "@" + db_host + "/" + db_name + "?charset=" + db_charset
-
+        if db_type == "mysql":
+            return "mysql://" + db_user + ":" + db_pass + "@" + db_host + "/" + db_name + "?charset=" + db_charset
+        elif db_type == "postgresql":
+            return f"postgresql+psycopg2://{db_user}:{db_pass}@{db_host}/{db_name}?client_encoding={db_charset}"
+        else:
+            return f"Error: invalid database type '{db_type}'"
 
 
     def load_config(self):
diff --git a/src/scheduling/models.py b/src/scheduling/models.py
index 1f477703..f6a15987 100644
--- a/src/scheduling/models.py
+++ b/src/scheduling/models.py
@@ -109,9 +109,16 @@ class AuraDatabaseModel():
         try:
             Playlist.is_empty()
         except sa.exc.ProgrammingError as e:
-            errcode = e.orig.args[0]
+            is_available = True
 
-            if errcode == 1146: # Error for no such table
+            # PostgreSQL table not available
+            if e.code == "f405":
+                is_available = False
+            # MariaDB table not available
+            elif e.orig.args[0] == 1146:
+                is_available = False
+
+            if not is_available:
                 model = AuraDatabaseModel()
                 model.recreate_db()
             else:
@@ -122,7 +129,7 @@ class AuraDatabaseModel():
     def recreate_db(systemexit = False):
         """
         Deletes all tables and re-creates the database.
-        """        
+        """
         Base.metadata.drop_all()
         Base.metadata.create_all()
         DB.session.commit()
@@ -166,11 +173,11 @@ class Timeslot(DB.Model, AuraDatabaseModel):
     default_show_playlist = relationship("Playlist",
                             primaryjoin="and_(Timeslot.timeslot_start==Playlist.timeslot_start, \
                                 Timeslot.default_show_playlist_id==Playlist.playlist_id, Timeslot.show_name==Playlist.show_name)",
-                            uselist=False, back_populates="timeslot")                            
+                            uselist=False, back_populates="timeslot")
     schedule_fallback = relationship("Playlist",
                             primaryjoin="and_(Timeslot.timeslot_start==Playlist.timeslot_start, \
                                 Timeslot.schedule_fallback_id==Playlist.playlist_id, Timeslot.show_name==Playlist.show_name)",
-                            uselist=False, back_populates="timeslot")                            
+                            uselist=False, back_populates="timeslot")
     show_fallback = relationship("Playlist",
                             primaryjoin="and_(Timeslot.timeslot_start==Playlist.timeslot_start, \
                                 Timeslot.show_fallback_id==Playlist.playlist_id, Timeslot.show_name==Playlist.show_name)",
@@ -203,7 +210,7 @@ class Timeslot(DB.Model, AuraDatabaseModel):
     topic = Column(String(256))
     musicfocus = Column(String(256))
     is_repetition = Column(Boolean())
-    
+
     # Transients
     active_entry = None
 
@@ -233,7 +240,7 @@ class Timeslot(DB.Model, AuraDatabaseModel):
         """
         timeslots = DB.session.query(Timeslot).\
             filter(Timeslot.timeslot_start >= date_from).\
-            order_by(Timeslot.timeslot_start).all()        
+            order_by(Timeslot.timeslot_start).all()
         return timeslots
 
 
@@ -278,7 +285,7 @@ class Timeslot(DB.Model, AuraDatabaseModel):
         playlist = self.playlist
 
         return {
-            "timeslot_id": self.timeslot_id,        
+            "timeslot_id": self.timeslot_id,
             "timeslot_start": self.timeslot_start.isoformat(),
             "timeslot_end": self.timeslot_end.isoformat(),
 
@@ -291,7 +298,7 @@ class Timeslot(DB.Model, AuraDatabaseModel):
             "comment": self.comment,
             "playlist_id": self.playlist_id,
             "schedule_default_id": self.schedule_default_id,
-            "show_default_id": self.show_default_id,            
+            "show_default_id": self.show_default_id,
             "schedule_fallback_id": self.schedule_fallback_id,
             "show_fallback_id": self.show_fallback_id,
             "station_fallback_id": self.station_fallback_id,
@@ -385,12 +392,12 @@ class Playlist(DB.Model, AuraDatabaseModel):
 
         Args:
             playlist_id (Integer):  The ID of the playlist
-        
+
         Returns:
             (Array<Playlist>):      An array holding the playlists
         """
         return DB.session.query(Playlist).filter(Playlist.playlist_id == playlist_id).order_by(Playlist.timeslot_start).all()
-    
+
 
     @staticmethod
     def is_empty():
@@ -488,7 +495,7 @@ class PlaylistEntry(DB.Model, AuraDatabaseModel):
     # Transients
     entry_start_actual = None # Assigned when the entry is actually played
     channel = None # Assigned when entry is actually played
-    queue_state = None # Assigned when entry is about to be queued    
+    queue_state = None # Assigned when entry is about to be queued
     status = None # Assigned when state changes
 
 
-- 
GitLab