From fa2d8b9dd7a26fb2390ff7e69465cbe61d823d94 Mon Sep 17 00:00:00 2001
From: David Huss <dh@atoav.com>
Date: Wed, 13 Jan 2021 12:54:54 +0100
Subject: [PATCH] More GET options, changed config, better README

---
 README.md                 |  25 ++++++--
 stechuhr_server/server.py | 131 ++++++++++++++++++++++++++++++++++----
 templates/all.html        |  13 ++++
 3 files changed, 148 insertions(+), 21 deletions(-)
 create mode 100644 templates/all.html

diff --git a/README.md b/README.md
index 3b20268..1d2e50e 100755
--- a/README.md
+++ b/README.md
@@ -30,16 +30,27 @@ pip3 install -r requirements.txt
 python3 stechuhr_server/server.py
 ```
 
-Check the output to find the config directory. You might want to run it as a different user tho.
 
-There is also a systemctl unit file that you probably need to change to your needs
 
-In production it makes sense to run stechuhr with gunicorn:
+## Deployment
 
-```bash
-source env/bin/activate
-gunicorn stechuhr_server.server:app
-```
+The _stechuhr-server_ is meant to run behind a reverse proxy server (e.g. NGINX) and as a systemd service on a Linux system. Gunicorn acts as a runner.
+
+Follow the steps listed above in _Run with python3-ven in production_. To install the stechuhr server. If the software runs in an initial test we need to set up a production environment
+
+1. Create a user called `wwwrun` 
+2. Copy the systemd unit file `stechuhr-server.service` to `/etc/systemd/system/stechuhr-server.service` and have a look at it. Note the line where it says: 
+    ```Environment="STECHUHR-SERVER_CONFIG_PATH=/etc/stechuhr-server/config.toml"```
+    This is where the stechuhr-server default config will be created on first startup. Make sure the user `wwwrun` is allowed to write the file there`
+3. Copy the stechuhr-server directory to `/srv/stechuhr-server`
+4. Create the directory `/srv/stechuhr-data` and `chown wwwrun:wwwrun /srv/stechuhr-data`
+5. Enable the service via `systemctl enable stechuhr-server`
+6. Start the service via `systemctl start stechuhr-server`
+7. Check the status via `systemctl status stechuhr-server` or display the log via `journalctl -fu stechuhr-server`
+
+After first start the default config file should be created, have a look at it and change the defaults (e.g. with `vim /etc/stechuhr-server/config.toml`). To update the changes run `systemctl restart stechuhr-server`.
+
+To make the server reachable from the outside a reverse proxy (like NGINX) needs to be setup. For this have a look at the example configuration file: `stechuhr.server.nginx.config` You may need to change a few things like the host or proxy_pass port.
 
 # Configuration
 
diff --git a/stechuhr_server/server.py b/stechuhr_server/server.py
index dcfbd0d..045ee57 100644
--- a/stechuhr_server/server.py
+++ b/stechuhr_server/server.py
@@ -3,7 +3,7 @@
 import re
 import datetime as dt
 import sqlite3
-from flask import Flask, request
+from flask import Flask, request, render_template
 from logging.config import dictConfig
 
 from .config import initialize_config
@@ -15,7 +15,10 @@ APPLICATION_NAME = "stechuhr-server"
 
 DEFAULT_CONFIG = """
 [application]
-ignore_get_requests = false
+# Warning: setting this to true can will display all visitor movments when the
+# server adress is visited. Only use for debugging!
+expose_visitor_data = true
+expose_current_visitor_number = true
 
 [database]
 # sqlite db path, use ":memory:" for RAM db
@@ -145,7 +148,7 @@ def get_records(conn, cursor):
         # Execute query and fill visitor with results
         cursor.execute(sqlite_select_query)
         records = cursor.fetchall()
-        visitors = [{"location":r[0], "entrance":r[1], "direction":r[2], "time":r[3], "id":r[4]} for r in records]
+        visitors = [{"location":r[0], "entrance":r[1], "direction":r[2], "time":dt.datetime.strptime(r[3], "%Y-%m-%d %H:%M:%S.%f"), "id":r[4]} for r in records]
     except sqlite3.OperationalError as e:
         # If there is no table or no records, just return an empty list
         app.logger.info('Couldn\'t retrieve visitor records: {}'.format(e))
@@ -250,22 +253,122 @@ def post():
 def get():
     """
     This function runs when a GET request on / is received.
-    Can be deactivated in the config with the ignore_get_requests setting
+    Can be deactivated in the config with the expose_visitor_data setting
     """
-    if config["application"]["ignore_get_requests"]:
+    if not config["application"]["expose_visitor_data"]:
         app.logger.info('501, Get Request Ignored due to config settings')
         return "", 501
     else:
         conn = sqlite3.connect(config["database"]["path"])
         cursor = conn.cursor()
         visitors = get_records(conn, cursor)
-        table = []
-        table.append("<table>")
-        table.append("<tr><th>location</th><th>entrance</th><th>direction</th><th>time</th><th>id</th></tr>")
-        for v in visitors:
-            table.append("<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>".format(v["location"], v["entrance"], v["direction"], v["time"], v["id"]))
-        table.append("</table>")
-        table = "\n".join(table)
         conn.close()
-        app.logger.info('200, Get visitors: {}'.format(table))
-        return table, 200
\ No newline at end of file
+        app.logger.info('200, Returning {} entries from visitor list'.format(len(visitors)))
+        return render_template('all.html', title=APPLICATION_NAME, visitors=visitors)
+
+
+# This gets run for each request
+@app.route('/today', methods = ['GET'])
+def today():
+    """
+    This function runs when a GET request on /today is received.
+    Can be deactivated in the config with the expose_visitor_data setting
+    """
+    if not config["application"]["expose_visitor_data"]:
+        app.logger.info('501, Get Request Ignored due to config settings')
+        return "", 501
+    else:
+        conn = sqlite3.connect(config["database"]["path"])
+        cursor = conn.cursor()
+        visitors = get_records(conn, cursor)
+        visitors = [v for v in visitors if v["time"].date() == dt.datetime.today().date()]
+        conn.close()
+        app.logger.info('200, Returning {} entries from visitor list (all locations)'.format(len(visitors)))
+        return render_template('all.html', title=APPLICATION_NAME, visitors=visitors)
+
+
+# This gets run for each request
+@app.route('/current', methods = ['GET'])
+def current():
+    """
+    This function runs when a GET request on /current is received.
+    Can be deactivated in the config with the expose_visitor_data setting
+    """
+    if not config["application"]["expose_visitor_data"]:
+        app.logger.info('501, Get Request Ignored due to config settings')
+        return "", 501
+    else:
+        conn = sqlite3.connect(config["database"]["path"])
+        cursor = conn.cursor()
+        visitors = get_records(conn, cursor)
+        visitors = filter_current_visitors(visitors)
+
+        conn.close()
+        app.logger.info('200, Returning {} entries from visitor list (currently inside all locations)'.format(len(visitors)))
+        return render_template('all.html', title=APPLICATION_NAME, visitors=visitors)
+
+
+# This gets run for each request
+@app.route('/number', methods = ['GET'])
+def number():
+    """
+    This function runs when a GET request on /number is received.
+    Can be deactivated in the config with the expose_current_visitor_number setting
+    """
+    if not config["application"]["expose_current_visitor_number"]:
+        app.logger.info('501, Get Request Ignored due to config settings')
+        return "", 501
+    else:
+        conn = sqlite3.connect(config["database"]["path"])
+        cursor = conn.cursor()
+        visitors = get_records(conn, cursor)
+        visitors = filter_current_visitors(visitors)
+        n = len(visitors)
+
+        conn.close()
+        app.logger.info('200, Returning number of visitors ({}, currently inside all locations)'.format(n))
+        return str(n), 200 
+
+
+def filter_current_visitors(visitors: list, hours: int=24) -> list:
+        """
+        Return a list of visitors which are currently inside all locations.
+        Note: to avoid accumulating errors look only at the last n hours
+        """
+        # Limit to last n hours
+        visitors = [v for v in visitors if v["time"] > dt.datetime.now() - dt.timedelta(hours=hours)]
+
+        # Get a unique list of IDs present
+        unique_ids = list(set([v["id"] for v in visitors]))
+        inside = []
+
+        # Per ID figure out if the user is inside
+        for iD in unique_ids:
+            movements = [v for v in visitors if v["id"] == iD]
+            incomings = [m for m in movements if m["direction"] == "in"]
+            outgoings = [m for m in movements if m["direction"] == "out"]
+
+            last_movement = None
+
+            if len(incomings) == 0:
+                # Visitor was only moving out in the last 24h, so they are not here
+                continue
+            elif not len(outgoings) == 0:
+                # Visitor was moving in AND out in the last 24h
+                last_in  = incomings[-1]
+                last_out = outgoings[-1]
+                # Are they still inside the location?
+                if last_out["time"] < last_in["time"]:
+                    last_movement = last_in
+                else:
+                    continue
+            else:
+                # Visitor was only moving in in the last 24h
+                last_in  = incomings[-1]
+                last_movement = last_in
+
+            # When the user is still here, append the last movement to the list
+            if last_movement is not None:
+                inside.append(last_movement)
+
+        return inside
\ No newline at end of file
diff --git a/templates/all.html b/templates/all.html
new file mode 100644
index 0000000..e6eef62
--- /dev/null
+++ b/templates/all.html
@@ -0,0 +1,13 @@
+<html>
+    <head>
+        <title>{{ title }}</title>
+    </head>
+    <body>
+        <table>
+            <tr><th></th><th>Location</th><th>Entrance</th><th>ID</th><th>Time</th></tr>
+            {% for visitor in visitors %}
+            <tr><td class="direction">{{ visitor.direction }}</td><td class="location">{{ visitor.location }}</td><td class="entrance">{{ visitor.entrance }}</td><td class="id">{{ visitor.id }}</td><td class="time">{{ visitor.time }}</td></tr>
+            {% endfor %}
+        </table>
+    </body>
+</html>
\ No newline at end of file
-- 
GitLab