mostly working templating for landing page

This commit is contained in:
2025-11-24 22:53:13 +01:00
parent 50c0f46207
commit 144d8aa844
11 changed files with 299 additions and 32 deletions

View File

@@ -4,6 +4,8 @@ import logging
from typing import Generator from typing import Generator
import datetime import datetime
from templating import SensorInfo
# main database connector class # main database connector class
# permission validation is not performed on this class # permission validation is not performed on this class
# perform those higher on the call stack # perform those higher on the call stack
@@ -36,10 +38,14 @@ class DatabaseConnect:
def display_users(self) -> tuple[tuple[int, str, int]]: def display_users(self) -> tuple[tuple[int, str, int]]:
self.cursor.execute("SELECT * FROM Users") self.cursor.execute("SELECT * FROM Users")
return self.cursor.fetchall() return self.cursor.fetchall()
def username_from_id(self, user_id):
self.cursor.execute("SELECT users.`username` FROM users WHERE `ID` = 1;")
return self.cursor.fetchone()
def view_valid_rooms(self, user_id) -> list[tuple[str, str]]: def view_valid_rooms(self, user_id) -> list[tuple[str, str, int]]:
self.cursor.execute( self.cursor.execute(
"SELECT rooms.name, rooms.shortname from permissions INNER JOIN rooms ON permissions.`roomID` = rooms.`ID` WHERE permissions.`userID` = ? AND permissions.`view` = 1;", "SELECT rooms.name, rooms.shortname, rooms.ID from permissions INNER JOIN rooms ON permissions.`roomID` = rooms.`ID` WHERE permissions.`userID` = ? AND permissions.`view` = 1;",
(user_id,)) (user_id,))
return self.cursor.fetchall() return self.cursor.fetchall()
@@ -55,9 +61,22 @@ class DatabaseConnect:
# self.cursor.execute("SELECT rooms.`ID` from rooms WHERE rooms.shortname = ?;",(shortname,)) # self.cursor.execute("SELECT rooms.`ID` from rooms WHERE rooms.shortname = ?;",(shortname,))
# return self.cursor.fetchone() # return self.cursor.fetchone()
def get_sensors_in_room(self, shortname) -> list[int]: def get_sensors_in_room_shortname(self, shortname) -> list[int]:
self.cursor.execute("SELECT devices.`ID` from devices LEFT JOIN rooms ON devices.`roomID` = rooms.ID WHERE rooms.shortname = ?;",(shortname,)) self.cursor.execute("SELECT Sensors.`ID` from Sensors LEFT JOIN rooms ON Sensors.`roomID` = rooms.ID WHERE rooms.shortname = ?;",(shortname,))
return [x[0] for x in self.cursor.fetchall()] return [x[0] for x in self.cursor.fetchall()]
def get_sensors_in_room_id(self, roomid) -> list[tuple[int, int]]:
self.cursor.execute("SELECT Sensors.`ID`, Sensors.`Type` from Sensors LEFT JOIN rooms ON Sensors.`roomID` = rooms.ID WHERE rooms.`ID` = ?;",(roomid,))
return [(x[0], x[1]) for x in self.cursor.fetchall()]
def get_sensorsinfo_by_sensorid(self, sensorid) -> SensorInfo | None:
self.cursor.execute("SELECT readings.`Timestamp`, readings.`reading` FROM readings WHERE readings.`sensorID` = ? ORDER BY readings.`Timestamp` DESC FETCH FIRST 1 ROWS ONLY;",(sensorid,))
fetch = self.cursor.fetchone()
if fetch is None:
return None
else:
return SensorInfo(0,sensorid, fetch[0], fetch[1])
def get_sensor_data(self, sensor_ID: int) -> Generator[tuple[datetime.datetime, float]]: def get_sensor_data(self, sensor_ID: int) -> Generator[tuple[datetime.datetime, float]]:
self.cursor.execute("SELECT Timestamp, reading FROM Readings WHERE `sensorID` = ? ORDER BY `Timestamp` DESC;",(sensor_ID,)) self.cursor.execute("SELECT Timestamp, reading FROM Readings WHERE `sensorID` = ? ORDER BY `Timestamp` DESC;",(sensor_ID,))
@@ -65,7 +84,7 @@ class DatabaseConnect:
yield row yield row
def get_sensor_type(self, sensor_ID): def get_sensor_type(self, sensor_ID):
self.cursor.execute("SELECT types.`type_desc` from types LEFT JOIN devices ON types.`ID` = devices.`type` WHERE devices.`ID` = ?;",(sensor_ID,)) self.cursor.execute("SELECT types.`type_desc` from types LEFT JOIN Sensors ON types.`ID` = Sensors.`type` WHERE Sensors.`ID` = ?;",(sensor_ID,))
return self.cursor.fetchone()[0] return self.cursor.fetchone()[0]
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -9,13 +9,18 @@ INSERT INTO Users (`ID`, `username`, `pwd`) VALUES (1,'nouser',NULL), (NULL,'Adm
CREATE TABLE Permissions (`userID` INT, `roomID` INT, `view` BOOLEAN DEFAULT 0, `purge_data` BOOLEAN DEFAULT 0, `administer` BOOLEAN DEFAULT 0); CREATE TABLE Permissions (`userID` INT, `roomID` INT, `view` BOOLEAN DEFAULT 0, `purge_data` BOOLEAN DEFAULT 0, `administer` BOOLEAN DEFAULT 0);
CREATE TABLE Rooms (`ID` INT NOT NULL PRIMARY KEY AUTO_INCREMENT, `name` TEXT, `shortname` TEXT UNIQUE); CREATE TABLE Rooms (`ID` INT NOT NULL PRIMARY KEY AUTO_INCREMENT, `name` TEXT, `shortname` TEXT UNIQUE);
CREATE TABLE Devices(`ID` INT NOT NULL PRIMARY KEY AUTO_INCREMENT, `type` INT, `roomID` INT, `mqttTopic` TEXT); CREATE TABLE Sensors(`ID` INT NOT NULL PRIMARY KEY AUTO_INCREMENT, `type` INT, `roomID` INT, `mqttTopic` TEXT);
CREATE TABLE Types(`ID` INT NOT NULL PRIMARY KEY AUTO_INCREMENT, `type_desc` TEXT); CREATE TABLE Types(`ID` INT NOT NULL PRIMARY KEY AUTO_INCREMENT, `type_desc` TEXT);
CREATE TABLE Devices(`ID` INT NOT NULL PRIMARY KEY AUTO_INCREMENT, `roomID` INT, `name` TEXT, `description` TEXT);
CREATE TABLE Readings(`sensorID` INT, `Timestamp` DATETIME, `reading` DOUBLE); CREATE TABLE Readings(`sensorID` INT, `Timestamp` DATETIME, `reading` DOUBLE);
CREATE INDEX `sensor_index` ON Readings (`sensorID`); CREATE INDEX `sensor_index` ON Readings (`sensorID`);
CREATE TABLE Log( `timestamp` TIMESTAMP, `type` INT, `message` TEXT);
INSERT INTO Rooms (`name`,`shortname`) VALUES ('101','101'),('102','102'),('210','210'),('211','211'),('215 - Studovna','215'); INSERT INTO Rooms (`name`,`shortname`) VALUES ('101','101'),('102','102'),('210','210'),('211','211'),('215 - Studovna','215');
INSERT INTO Devices (`type`, `roomID`,`mqttTopic`) VALUES (1,1,"101/floortemp"),(1,1,"101/ceiltemp"),(2,1,"101/humidity"),(1,3,"210/temp"),(1,5,"215/temp"),(2,5,"215/humidity"); INSERT INTO Sensors (`type`, `roomID`,`mqttTopic`) VALUES (0,1,"101/floortemp"),(0,1,"101/ceiltemp"),(1,1,"101/humidity"),(0,3,"210/temp"),(0,5,"215/temp"),(1,5,"215/humidity");
INSERT INTO Types (type_desc) VALUES ("Temperature"), ('Humidity'); INSERT INTO Types (type_desc) VALUES ("Temperature"), ('Humidity');
INSERT INTO `readings`(`Timestamp`,`reading`,`sensorID`) VALUES INSERT INTO `readings`(`Timestamp`,`reading`,`sensorID`) VALUES
('2025-11-12 00:00:00',25.7,1),('2025-11-12 01:00:00',26.7,1),('2025-11-12 02:00:00',27.4,1),('2025-11-12 02:04:00',28.0,1),('2025-11-12 03:22:00',28.2,1), ('2025-11-12 00:00:00',25.7,1),('2025-11-12 01:00:00',26.7,1),('2025-11-12 02:00:00',27.4,1),('2025-11-12 02:04:00',28.0,1),('2025-11-12 03:22:00',28.2,1),
@@ -31,7 +36,10 @@ SELECT rooms.name, rooms.shortname from permissions INNER JOIN rooms ON permissi
SELECT rooms.`ID` from rooms WHERE rooms.shortname = '101'; SELECT rooms.`ID` from rooms WHERE rooms.shortname = '101';
SELECT devices.`ID` from devices LEFT JOIN rooms ON devices.`roomID` = rooms.ID WHERE rooms.shortname = '101'; SELECT Sensors.`ID` from Sensors LEFT JOIN rooms ON Sensors.`roomID` = rooms.ID WHERE rooms.shortname = '101';
SELECT types.`type_desc` from types LEFT JOIN devices ON types.`ID` = devices.`type` WHERE devices.`ID` = 1; SELECT types.`type_desc` from types LEFT JOIN Sensors ON types.`ID` = Sensors.`type` WHERE Sensors.`ID` = 1;
SELECT permissions.`view` FROM permissions LEFT JOIN rooms ON permissions.`roomID` = rooms.ID WHERE rooms.shortname = '101' AND `userID` = 1; SELECT permissions.`view` FROM permissions LEFT JOIN rooms ON permissions.`roomID` = rooms.ID WHERE rooms.shortname = '101' AND `userID` = 1;
# get latest reading from a sensor
SELECT readings.`Timestamp`, readings.`reading` FROM readings WHERE readings.`sensorID` = '1' ORDER BY readings.`Timestamp` DESC FETCH FIRST 1 ROWS ONLY;

19
static/scripts.js Normal file
View File

@@ -0,0 +1,19 @@
function tableSearch(){
var table, input, tr, td, row_content;
table = document.getElementById("tableRooms");
input = document.getElementById("tableRoomSearch").value.toUpperCase();
tr = table.getElementsByTagName("tr");
for (let i = 0; i < tr.length; i++) {
td = tr[i].getElementsByTagName("td")[0];
if(td){
row_content = td.textContent || td.innerText;
if (row_content.toUpperCase().indexOf(input) > -1) {
tr[i].style.display = "";
}
else{
tr[i].style.display ="none";
}
}
}
}

View File

@@ -6,7 +6,7 @@ a{
text-decoration: none; text-decoration: none;
} }
.main{ .main-flex{
display: flex; display: flex;
flex-direction: row; flex-direction: row;
} }
@@ -29,7 +29,7 @@ a{
font-weight: normal; font-weight: normal;
} }
.content{ .right-flex{
flex: 1 60vw; flex: 1 60vw;
} }
@@ -43,7 +43,7 @@ a{
text-align: right; text-align: right;
} }
.main-content{ .content{
padding-left: 1em; padding-left: 1em;
} }
@@ -75,10 +75,11 @@ thead{
tbody tr{ tbody tr{
border-bottom: 0.1em darkgray solid; border-bottom: 0.1em darkgray solid;
} }
.missing-data{ .data-missing{
background-color: gray; background-color: gray;
color: transparent
} }
.late-data{ .data-late{
background-color: orange; background-color: orange;
} }
.room-table-elem, .searchbar{ .room-table-elem, .searchbar{

21
static_text.toml Normal file
View File

@@ -0,0 +1,21 @@
# Definitions for various pieces of static text
# Raw HTML may be inserted for further formatting purposes
pagetitle = "Room air quality Example Inc."
# used in the top left corner for identifying the website
# logos and links to the main pages are good ideas to insert here
top_branding = "Example Inc. <br>Room air quality monitoring"
# user greeting after they log in, this is the part that goes
# before the name itself
user_greeting_before = "Welcome back "
# followed by the part after the username
user_greeting_after= "!"
# landing information also displayed on the main page of a logged in user
# or a non logged in user if allowed
# this is typically a paragraph introducing the nature of the service to the user
landing_information = "This service can be used to monitor the air quality in most rooms of the Example Inc. headquarters so that you and your coworkers may know the room is suitable for use or may prepare the room before using it."

49
templating.py Normal file
View File

@@ -0,0 +1,49 @@
from flask import url_for, Flask
from jinja2 import FileSystemLoader
import tomllib
from os import sep
from collections import namedtuple
def static_test_load():
with open("static_text.toml", "rb") as f:
return tomllib.load(f)
UserInfo = namedtuple('UserInfo',['id', 'name'])
RoomInfo = namedtuple('RoomInfo',['name', 'shortcode', 'roomdata'])
# state is an int not in db
# 0 - value valid
# 1 - value late
# 2 - value missing
SensorInfo = namedtuple('SensorInfo',['state','type','timestamp','reading'])
# base class for inheriting other more specific pages
class BasePage():
def __init__(
self,
jijna_env,
target_path = "base.html.jinja" ,
statictext = None,
) -> None:
self.env = jijna_env
self.target = target_path
self.rendervars = static_test_load() if statictext is None else statictext
def render(self):
url_for('static', filename="styles.css")
url_for('static', filename="scripts.css")
template = self.env.get_template(self.target)
return template.render(self.rendervars)
class LandingPage(BasePage):
def __init__(
self,
jinja_env,
userinfo,
roominfo,
target_path="landing.html.jinja",
statictext=None,
) -> None:
super().__init__(jinja_env, target_path, statictext)
self.rendervars["userinfo"] = userinfo
self.rendervars["roominfo"] = roominfo

32
test.py Normal file
View File

@@ -0,0 +1,32 @@
from jinja2 import Template
text = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" /> <!--It is necessary to use the UTF-8 encoding with plotly graphics to get e.g. negative signs to render correctly -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
{{ text1 }}
{{ text2 }}
</body>
</html>
"""
tmpl = Template(text)
print(
tmpl.render(
text1 = "TEST"
)
)

63
web.py
View File

@@ -1,35 +1,71 @@
from db_connect import DatabaseConnect from db_connect import DatabaseConnect
from flask import Flask, abort, session from flask import Flask, abort, session
from jinja2 import Template from jinja2 import FileSystemLoader, Template
import plotly.express as px import plotly.express as px
from plotly.subplots import make_subplots from plotly.subplots import make_subplots
import plotly.graph_objects as go import plotly.graph_objects as go
import logging import logging
from os import sep from os import sep
import datetime
import templating
app = Flask(__name__) app = Flask(__name__)
db = DatabaseConnect() db = DatabaseConnect()
Flask.jinja_options["loader"] = FileSystemLoader("web")
env = app.create_jinja_environment()
DISPLAYEDTYPES = 2
TDLATE = datetime.timedelta(hours=8)
# before and after
TYPEUNITS = [
"°C",
"%"
]
@app.route("/") @app.route("/")
def index(): def index():
avail_rooms = db.view_valid_rooms(1) # no user handling yet so we get user of ID 1 (not logged in)
outtext = "<h1>Available rooms</h1><ul>" active_user = 1
if len(avail_rooms) == 0: #populate userinfo
outtext += "<li>You have no rooms you can view</li>" user_info = templating.UserInfo(id=active_user, name=db.username_from_id(active_user)[0])
else:
for room in avail_rooms: rooms_info = []
outtext += f"<li><a href=/room/{room[1]}>{room[0]}</li>" for room in db.view_valid_rooms(active_user):
outtext += "</ul>" sensor_info = []
return outtext sensors_avail = db.get_sensors_in_room_id(room[2])
for i in range(DISPLAYEDTYPES):
sensors_with_type = [x for x in sensors_avail if x[1] == i]
# TODO: handle more than one sensor in one room
if sensors_with_type:
reading = db.get_sensorsinfo_by_sensorid(sensors_with_type[0][0])
if reading is None:
sensor_info.append(templating.SensorInfo(2,None, None, ""))
continue
reading = templating.SensorInfo(1 if reading.timestamp + TDLATE < datetime.datetime.now() else 0,
reading.type,
reading.timestamp,
f"{reading.reading}{TYPEUNITS[i]}")
sensor_info.append(reading)
else:
sensor_info.append(templating.SensorInfo(2,None, None, ""))
rooms_info.append(templating.RoomInfo(room[0], room[1], sensor_info))
out_html = templating.LandingPage(env, user_info,rooms_info)
return out_html.render()
@app.route('/room/<room_name>') @app.route('/room/<room_name>')
def room_page(room_name=None): def room_page(room_name=None):
if not db.user_has_room_perms(1,room_name): if not db.user_has_room_perms(1,room_name):
abort(403) abort(403)
sensor_list = db.get_sensors_in_room(room_name) sensor_list = db.get_sensors_in_room_shortname(room_name)
fig = make_subplots(rows=1, cols=len(sensor_list)) fig = make_subplots(rows=1, cols=len(sensor_list))
for idx, sensorID in enumerate(sensor_list): for idx, sensorID in enumerate(sensor_list):
lst = [x for x in db.get_sensor_data(sensorID)] lst = [x for x in db.get_sensor_data(sensorID)]
@@ -47,4 +83,7 @@ def room_page(room_name=None):
px_jinja_data = {"fig":fig.to_html(full_html=False)} px_jinja_data = {"fig":fig.to_html(full_html=False)}
with open(template_path,'r') as template_file: with open(template_path,'r') as template_file:
j2_template = Template(template_file.read()) j2_template = Template(template_file.read())
return j2_template.render(px_jinja_data) return j2_template.render(px_jinja_data)
if __name__ == "__main__":
app.run(debug=True)

36
web/base.html.jinja Normal file
View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=\, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css')}}">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
<script src="{{ url_for('static', filename='scripts.js') }}"></script>
<title>{{ pagetitle }}</title>
</head>
<body>
<div class="main-flex">
<div class="navbar">
<div class="topbranding">{{ top_branding }}</div>
<div class="navbar-inner">
<b><a href="index.html">Home</a></b><br>
<i class="bi bi-arrow-down-square-fill">&nbsp;</i>Rooms<br>
<hr>
<a href="device.html">Device Management</a><br>
<a href="users.html">User Management</a>
</div>
</div>
<div class="right-flex">
<div class="topline">
<div class="user section"> {{ userinfo.name }}</div>
<div class="logout section"> <a href="logout">
Logout <i class="bi bi-power"></i>
</a></div>
</div>
{% block content %}
{% endblock content %}
</div>
</div>
</body>
</html>

View File

@@ -26,12 +26,12 @@
} }
} }
</script> </script>
<title>{{ Pagetitle }}</title> <title>{{ pagetitle }}</title>
</head> </head>
<body> <body>
<div class="main"> <div class="main-flex">
<div class="navbar"> <div class="navbar">
<div class="topbranding">{{ topbranding }}</div> <div class="topbranding">{{ top_branding }}</div>
<div class="navbar-inner"> <div class="navbar-inner">
<b><a href="index.html">Home</a></b><br> <b><a href="index.html">Home</a></b><br>
<i class="bi bi-arrow-down-square-fill">&nbsp;</i>Rooms<br> <i class="bi bi-arrow-down-square-fill">&nbsp;</i>Rooms<br>
@@ -44,7 +44,7 @@
<a href="users.html">User Management</a> <a href="users.html">User Management</a>
</div> </div>
</div> </div>
<div class="content"> <div class="right-flex">
<div class="topline"> <div class="topline">
<div class="user section"> {{ user }}</div> <div class="user section"> {{ user }}</div>
@@ -52,7 +52,7 @@
Logout <i class="bi bi-power"></i> Logout <i class="bi bi-power"></i>
</a></div> </a></div>
</div> </div>
<div class="main-content"> <div class="content">
<p> {{ user_greeting }}</p> <p> {{ user_greeting }}</p>
<p> {{ ladning_information }}</p> <p> {{ ladning_information }}</p>
<div class="rooms-table"> <div class="rooms-table">

43
web/landing.html.jinja Normal file
View File

@@ -0,0 +1,43 @@
{% extends "base.html.jinja" %}
{% block content %}
<div class="content">
<p> {{ user_greeting_before }}{{userinfo.name}}{{user_greeting_after}}</p>
<p> {{ landing_information }} </p>
<div class="rooms-table">
<table>
<thead>
<tr class="table-header">
<th class="room-table-elem">Room name:</th>
<th>Temperature</th>
<th>Humidity</th>
</tr>
<tr class="searchbar">
<td colspan="255">
<i class="bi bi-search"></i>&nbsp;
<input type="text" id="tableRoomSearch" placeholder="Search by room name..." onkeyup="tableSearch()">
</td>
</tr>
</thead>
<tbody id="tableRooms">
{% for room in roominfo %}
<tr>
<td><a href="./room/{{ room.shortcode }}">{{ room.name }}</a>
{%- for sensorinfo in room.roomdata %}
{% if sensorinfo.state == 0 %}
<td class="data-valid">
{% elif sensorinfo.state == 1 %}
<td class="data-late">
{% else %}
<td class="data-missing">
{% endif %}
{{ sensorinfo.reading }}
</td>
{%- endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock content %}