summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMark Powers <mark@marks.kitchen>2023-01-02 21:36:54 -0600
committerMark Powers <mark@marks.kitchen>2023-01-02 21:36:54 -0600
commitd0969bd6886a81dc59c5c02b12468a97b3c4cf58 (patch)
treeef44a9d1a0cf29e582f5eff90f5f6a33092fd58f
Initial commit
-rw-r--r--Dockerfile10
-rw-r--r--index.html248
-rwxr-xr-xinsert_book.sh6
-rw-r--r--server.py136
4 files changed, 400 insertions, 0 deletions
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..412b929
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,10 @@
+FROM ubuntu
+
+RUN apt update -y && apt install -y python3-pip
+RUN pip install psycopg2-binary
+
+COPY index.html /src/index.html
+COPY server.py /src/server.py
+
+WORKDIR /src
+CMD python3 server.py
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..64490b3
--- /dev/null
+++ b/index.html
@@ -0,0 +1,248 @@
+<html>
+ <head>
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <style>
+ p {
+ #margin-top: 0;
+ #margin-bottom: 0;
+ }
+ label.number {
+ margin-right: 1rem;
+ }
+ label.multi-label {
+ padding: 1rem;
+ border: 1px solid #3366ff;
+ }
+ input[type="checkbox"].multi-checkbox {
+ display: none;
+ }
+ input[type="checkbox"] {
+ height: 2rem;
+ width: 2rem;
+ }
+ input:checked + label{
+ background-color: #99ccff;
+ }
+ input[type="number"] {
+ height: 3rem;
+ }
+ input[type="range"] {
+ width: 60%;
+ }
+ .prompt {
+ margin-bottom: 2rem;
+ }
+ textarea {
+ width: 80ch;
+ height: 5rem;
+ }
+ button {
+ padding: 1rem;
+ border: 1px solid #3366ff;
+ }
+ </style>
+ </head>
+ <body>
+ <nav></nav>
+ <div class="main">
+ <div id="books"></div>
+ <div id="prompts"></div>
+ <div><button onclick="on_submit()">Submit</button></div>
+ <script>
+ function insert_multiple_select(prompt, prompt_id, items){
+ let root_el = document.getElementById("prompts")
+ let wrapper_el = document.createElement("div")
+ wrapper_el.setAttribute("class", "prompt multiple_select")
+
+ let prompt_text_el = document.createElement("p")
+ prompt_text_el.innerHTML = prompt
+ wrapper_el.appendChild(prompt_text_el)
+ items.forEach(item => {
+ let checkbox_el = document.createElement("input")
+ checkbox_el.setAttribute("id", prompt_id+"__"+item["id"])
+ checkbox_el.setAttribute("type", "checkbox")
+ checkbox_el.setAttribute("class", "multi-checkbox")
+ wrapper_el.appendChild(checkbox_el)
+
+ let item_label = document.createElement("label")
+ item_label.setAttribute("for", prompt_id+"__"+item["id"])
+ item_label.setAttribute("class", "multi-label")
+ item_label.innerHTML = item["display"]
+ wrapper_el.appendChild(item_label)
+ })
+ root_el.appendChild(wrapper_el)
+ }
+ function insert_number(prompt, prompt_id){
+ let root_el = document.getElementById("prompts")
+ let wrapper_el = document.createElement("div")
+ wrapper_el.setAttribute("class", "prompt")
+
+ let item_label = document.createElement("label")
+ item_label.setAttribute("for", prompt_id)
+ item_label.setAttribute("class", "number")
+ item_label.innerHTML = prompt
+
+ let input_el = document.createElement("input")
+ input_el.setAttribute("id", prompt_id)
+ input_el.setAttribute("type", "number")
+ wrapper_el.appendChild(item_label)
+ wrapper_el.appendChild(input_el)
+ root_el.appendChild(wrapper_el)
+ }
+ function insert_range(prompt, prompt_id, min, max){
+ let root_el = document.getElementById("prompts")
+ let wrapper_el = document.createElement("div")
+ wrapper_el.setAttribute("class", "prompt")
+
+ let item_label = document.createElement("label")
+ item_label.setAttribute("for", prompt_id)
+ item_label.setAttribute("class", "number")
+ item_label.innerHTML = prompt
+
+ let checkbox_el = document.createElement("input")
+ checkbox_el.setAttribute("id", prompt_id+"_checkbox")
+ checkbox_el.setAttribute("type", "checkbox")
+
+ let input_el = document.createElement("input")
+ input_el.setAttribute("id", prompt_id)
+ input_el.setAttribute("type", "range")
+ input_el.setAttribute("min", min)
+ input_el.setAttribute("max", max)
+ input_el.setAttribute("value", Math.floor((max+min)/2))
+ wrapper_el.appendChild(item_label)
+ wrapper_el.appendChild(checkbox_el)
+ wrapper_el.appendChild(input_el)
+ root_el.appendChild(wrapper_el)
+ }
+ function insert_textarea(prompt, prompt_id){
+ let root_el = document.getElementById("prompts")
+ let wrapper_el = document.createElement("div")
+ wrapper_el.setAttribute("class", "prompt")
+
+ let item_label = document.createElement("label")
+ item_label.setAttribute("for", prompt_id)
+ item_label.setAttribute("class", "number")
+ item_label.innerHTML = prompt
+
+ let input_el = document.createElement("textarea")
+ input_el.setAttribute("id", prompt_id)
+
+ let label_wrapper = document.createElement("div")
+ label_wrapper.appendChild(item_label)
+ wrapper_el.appendChild(label_wrapper)
+ wrapper_el.appendChild(input_el)
+ root_el.appendChild(wrapper_el)
+ }
+ function insert_book(title){
+ let safe_id = title.replace(/\s/g, '');
+
+ let item_label = document.createElement("label")
+ item_label.setAttribute("for", safe_id)
+ item_label.setAttribute("class", "number")
+ item_label.innerHTML = title
+
+ let input_el = document.createElement("input")
+ input_el.setAttribute("id", safe_id)
+ input_el.setAttribute("type", "number")
+ input_el.setAttribute("placeholder", "pages")
+
+ let item_complete_label = document.createElement("label")
+ item_complete_label.setAttribute("for", safe_id+"_complete")
+ item_complete_label.innerHTML = "Completed?"
+
+ let input_complete_el = document.createElement("input")
+ input_complete_el.setAttribute("id", safe_id+"_complete")
+ input_complete_el.setAttribute("type", "checkbox")
+
+ let wrapper_el = document.createElement("div")
+ wrapper_el.setAttribute("class", "prompt")
+ wrapper_el.appendChild(item_label)
+ wrapper_el.appendChild(input_el)
+ wrapper_el.appendChild(item_complete_label)
+ wrapper_el.appendChild(input_complete_el)
+
+ let root_el = document.getElementById("books")
+ root_el.appendChild(wrapper_el)
+ }
+ function item(i){
+ return {"id": i, "display": i}
+ }
+ async function enqueue_data(path, payload, do_alert=false){
+ payload["timestamp"] = Date.now()
+ await fetch(path, {method: "POST", body: JSON.stringify(payload)})
+ if(do_alert){
+ alert("saved!")
+ }
+ }
+ function submit_geolocation(){
+ navigator.geolocation.getCurrentPosition(function(pos){
+ enqueue_data("/submit_payload", {
+ "geoposition": {
+ "latitude": pos.coords.latitude,
+ "longitude": pos.coords.longitude
+ }
+ })
+ });
+ }
+ function on_submit(){
+ let payload = {}
+ document.querySelectorAll("#prompts input[type=checkbox].multi-checkbox").forEach(el => {
+ let p = el.id.split("__", 2)
+ if(!(p[0] in payload)){
+ payload[p[0]] = {}
+ }
+ payload[p[0]][p[1]] = el.checked
+ })
+ document.querySelectorAll("#prompts input[type=number]").forEach(el => {
+ payload[el.id] = el.value
+ })
+ document.querySelectorAll("#prompts input[type=checkbox]:checked + input[type=range]").forEach(el => {
+ payload[el.id] = el.value
+ })
+ document.querySelectorAll("#prompts textarea").forEach(el => {
+ payload[el.id] = el.value
+ })
+ payload["books"] = []
+ document.querySelectorAll("#books .prompt").forEach(el => {
+ if(el.childNodes[1].value){
+ book = {
+ "title": el.childNodes[0].innerHTML,
+ "pages": el.childNodes[1].value,
+ "complete": el.childNodes[3].checked
+ }
+ payload["books"].push(book)
+ }
+ })
+ console.log(payload["books"])
+ enqueue_data("/submit_payload", payload, do_alert=true)
+ submit_geolocation()
+ }
+ window.onload = function(){
+ fetch("/forms").then(res => res.json()).then(data => {
+ data.forEach(form => {
+ switch(form["type"]) {
+ case "multiple_select":
+ insert_multiple_select(form["prompt"], form["prompt_id"], form["extra"])
+ break;
+ case "number":
+ insert_number(form["prompt"], form["prompt_id"])
+ break;
+ case "range":
+ insert_range(
+ form["prompt"], form["prompt_id"], form["extra"]["min"], form["extra"]["max"])
+ break;
+ case "textarea":
+ insert_textarea(form["prompt"], form["prompt_id"])
+ break;
+ }
+ })
+ })
+
+ fetch("/books").then(res => res.json()).then(data => {
+ data.forEach(book => insert_book(book))
+ })
+ }
+ </script>
+ </div>
+ </body>
+</html>
diff --git a/insert_book.sh b/insert_book.sh
new file mode 100755
index 0000000..cd7c782
--- /dev/null
+++ b/insert_book.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+
+[[ -z $1 ]] && echo "Usage: ./insert_book <title>" && exit 1
+
+docker-compose exec db psql -U tracking -d tracking -c \
+ "INSERT INTO book (title, completed) VALUES ('$1', FALSE)"
diff --git a/server.py b/server.py
new file mode 100644
index 0000000..d970860
--- /dev/null
+++ b/server.py
@@ -0,0 +1,136 @@
+from urllib.parse import urlparse, parse_qs
+from datetime import datetime
+from http.server import HTTPServer, BaseHTTPRequestHandler
+import json
+import psycopg2
+import re
+
+class TrackerHTTPRequestHandler(BaseHTTPRequestHandler):
+ def __init__(self, *args):
+ BaseHTTPRequestHandler.__init__(self, *args)
+
+ def insert(self, datatype=None, key=None, value=None, created=None):
+ if value:
+ print("inserting", datatype, key, value)
+ with conn.cursor() as cur:
+ if key:
+ cur.execute("INSERT INTO datapoint (datatype, key, value, created) VALUES (%s, %s, %s, %s)", (datatype, key, value, created))
+ else:
+ cur.execute("INSERT INTO datapoint (datatype, value, created) VALUES (%s, %s, %s)", (datatype, value, created))
+ conn.commit()
+
+ def insert_book_datapoint(self, title, pages, complete, created=None):
+ with conn.cursor() as cur:
+ cur.execute("SELECT id FROM book where title = %s", (title,))
+ row = cur.fetchone()
+ book_id = row[0]
+ print(book_id, pages, created)
+ cur.execute("INSERT INTO book_datapoint (book_id, pages, created) VALUES (%s, %s, %s)", (book_id, pages, created))
+ if complete:
+ cur.execute("UPDATE book SET completed = TRUE where id = %s", (book_id,))
+ conn.commit()
+
+ def do_GET(self):
+ u = urlparse(self.path)
+ print("GET", u.path)
+ if u.path == "/":
+ with open("index.html", "rb") as f:
+ self.send_response(200)
+ self.end_headers()
+ self.wfile.write(f.read())
+ elif date_pattern.match(u.path[1:]):
+ with conn.cursor() as cur:
+ cur.execute("SELECT datatype, key, value FROM datapoint where date(created) = %s", (u.path[1:],))
+ items = []
+ for datatype, key, value in cur:
+ items.append({
+ "datatype": datatype,
+ "key": key,
+ "value": value
+ })
+ self.send_response(200)
+ self.end_headers()
+ self.wfile.write(json.dumps(items).encode("utf-8"))
+ elif u.path == "/books":
+ with conn.cursor() as cur:
+ cur.execute("SELECT title FROM book WHERE completed = FALSE")
+ items = []
+ for row in cur:
+ items.append(row[0])
+ self.send_response(200)
+ self.end_headers()
+ self.wfile.write(json.dumps(items).encode("utf-8"))
+ elif u.path == "/forms":
+ # multiple_select, prompt, prompt_id, items [{id, display}]
+ # number, prompt, prompt_id
+ # range, prompt, prompt_id, min, max
+ # textarea, prompt, prompt_id
+ with conn.cursor() as cur:
+ cur.execute("SELECT type, prompt, prompt_id, extra FROM form")
+ items = []
+ for row in cur:
+ items.append({
+ "type": row[0],
+ "prompt": row[1],
+ "prompt_id": row[2],
+ "extra": row[3],
+ })
+ self.send_response(200)
+ self.end_headers()
+ self.wfile.write(json.dumps(items).encode("utf-8"))
+ else:
+ self.send_response(404)
+ self.end_headers()
+ self.wfile.write(b'not found')
+
+ def do_POST(self):
+ u = urlparse(self.path)
+ length = int(self.headers['Content-Length'])
+ post_data = json.loads(self.rfile.read(length).decode('utf-8'))
+ print("POST", u.path)
+ if u.path == "/submit_payload":
+ timestamp = datetime.fromtimestamp(int(post_data["timestamp"]/1000))
+ del post_data["timestamp"]
+
+ for book in post_data.get("books", []):
+ try:
+ self.insert_book_datapoint(**book, created=timestamp)
+ except Exception as e:
+ # Eventually add error messaging
+ print("ERROR", e)
+ pass
+ post_data.pop("books", None)
+
+ for k in post_data.keys():
+ if isinstance(post_data[k], dict):
+ for key, value in post_data[k].items():
+ self.insert(datatype=k, key=key, value=value, created=timestamp)
+ else:
+ self.insert(datatype=k, value=post_data[k], created=timestamp)
+ self.send_response(204)
+ self.end_headers()
+ self.wfile.write(b"")
+ else:
+ self.send_response(404)
+ self.end_headers()
+ self.wfile.write(b'not found')
+
+
+def setup_db():
+ with conn.cursor() as cur:
+ cur.execute("CREATE TABLE IF NOT EXISTS datapoint (id SERIAL PRIMARY KEY, created TIMESTAMP, datatype TEXT, key TEXT, value TEXT);")
+ cur.execute("CREATE TABLE IF NOT EXISTS outside_weather (id SERIAL PRIMARY KEY, created TIMESTAMP, temp FLOAT8, humidity FLOAT8, pressure FLOAT8, uvi FLOAT8, dew_point FLOAT8, wind_speed FLOAT8, wind_guest FLOAT8, wind_deg FLOAT8);")
+ cur.execute("CREATE TABLE IF NOT EXISTS book (id SERIAL PRIMARY KEY, title TEXT, completed BOOLEAN);")
+ cur.execute("CREATE TABLE IF NOT EXISTS book_datapoint (id SERIAL PRIMARY KEY, created TIMESTAMP, book_id SERIAL, pages TEXT, CONSTRAINT fk_book FOREIGN KEY(book_id) REFERENCES book(id));")
+ cur.execute("CREATE TABLE IF NOT EXISTS email (id SERIAL PRIMARY KEY, created TIMESTAMP, username TEXT, domain TEXT);")
+ cur.execute("CREATE TABLE IF NOT EXISTS form (id SERIAL PRIMARY KEY, type TEXT, prompt TEXT, prompt_id TEXT, extra JSON);")
+ conn.commit()
+
+date_pattern = re.compile(r"(\d{4})-(\d{2})-(\d{2})")
+conn = psycopg2.connect(host="db", dbname="tracking", user="tracking", password="password")
+if __name__ == "__main__":
+ setup_db()
+ print("Starting http server")
+ http = HTTPServer(("", 8000), TrackerHTTPRequestHandler)
+ print("serving forever")
+ http.serve_forever()