Browse Source

Big update

Kirk Trombley 4 years ago
parent
commit
319bcdbcb0
11 changed files with 1290 additions and 894 deletions
  1. 61 0
      .drone.yml
  2. 1 0
      .gitignore
  3. 3 3
      client/package.json
  4. 140 106
      client/src/App.js
  5. 1 3
      client/src/api.js
  6. 977 658
      client/yarn.lock
  7. 0 17
      deploy.sh
  8. 18 0
      server/Dockerfile
  9. 81 77
      server/app.py
  10. 0 22
      server/gendb.py
  11. 8 8
      server/requirements.txt

+ 61 - 0
.drone.yml

@@ -0,0 +1,61 @@
+kind: pipeline
+type: docker
+name: ui
+
+steps:
+- name: build-ui
+  image: node:12.7.0
+  volumes:
+  - name: build
+    path: /build/
+  commands:
+  - cd client
+  - yarn install
+  - yarn build
+  - mv build/* /build/
+  when:
+    branch:
+    - master
+- name: upload
+  image: plugins/s3
+  volumes:
+  - name: build
+    path: /build/
+  settings:
+    access_key:
+      from_secret: minio_access_key
+    secret_key:
+      from_secret: minio_secret_key
+    bucket: vacation-planner
+    source: /build/**/*
+    strip_prefix: /build
+    target: /vacation-planner
+    path_style: true
+    endpoint:
+      from_secret: minio_target
+    
+volumes:
+- name: build
+  temp: {}
+
+---
+kind: pipeline
+type: docker
+name: server
+
+steps:
+- name: publish-server  
+  image: plugins/docker
+  settings:
+    context: ./server
+    dockerfile: ./server/Dockerfile
+    username: 
+        from_secret: registry_username
+    password: 
+        from_secret: registry_password
+    repo: registry.hiram.services/vacation-planner-server
+    tags: latest
+    registry: registry.hiram.services
+  when:
+    branch:
+    - master

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+.vscode/

+ 3 - 3
client/package.json

@@ -7,9 +7,9 @@
     "@testing-library/jest-dom": "^4.2.4",
     "@testing-library/react": "^9.3.2",
     "@testing-library/user-event": "^7.1.2",
-    "react": "^16.12.0",
-    "react-dom": "^16.12.0",
-    "react-scripts": "3.4.0"
+    "react": "17.0.2",
+    "react-dom": "17.0.2",
+    "react-scripts": "4.0.3"
   },
   "scripts": {
     "start": "react-scripts start",

+ 140 - 106
client/src/App.js

@@ -1,6 +1,19 @@
-import React, { useState, useEffect, useRef } from 'react';
+import React, { useState, useEffect, useRef, useCallback } from 'react';
 import { getAvailability, setAvailability } from './api';
 
+
+// adapted from https://stackoverflow.com/a/60316951/13917490
+const START_DATE = new Date('05/30/2021');
+const END_DATE = new Date('09/01/2021');
+const DATES = Array.from(
+  { length: ((END_DATE - START_DATE) / 864e5) + 1 },
+  (_, i) => {
+    const date = new Date();
+    date.setDate(START_DATE.getDate()+i);
+    return { month: date.getMonth() + 1, day: date.getDate() };
+  }
+);
+
 const NameForm = ({ onNameSet }) => {
   const [name, setName] = useState("");
 
@@ -38,12 +51,12 @@ const colors = {
   maybe: "#ff0",
 }
 
-const nextState = {
+const getNext = (user, availability) => ({
   yes: "no",
   no: "maybe",
   maybe: "unknown",
   unknown: "yes",
-}
+})[availability.find(({ name }) => name === user)?.status ?? "unknown"];
 
 const pad = num => {
   let snum = "" + num
@@ -53,7 +66,7 @@ const pad = num => {
 
 const Tile = ({ user, month, day, availability, onClick }) => (
   <div 
-    onClick={onClick}
+    onClick={() => onClick(month, day, getNext(user, availability))}
     style={{
       display: "flex",
       flexFlow: "column nowrap",
@@ -94,53 +107,130 @@ const chunk = (arr, len) => {
   return chunks;
 }
 
-function App() {
-  const [name, setName] = useState(null);
+const DaysOfWeek = () => (
+  <div
+    style={{
+      display: "flex",
+      flexFlow: "row nowrap",
+    }}
+  >
+    {
+      ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
+      .map(day => (
+        <div
+          key={day}
+          style={{
+            width: "10em",
+            height: "1.5em",
+            border: "1px solid black",
+            margin: "2px 2px 2px 2px",
+          }}
+        >
+          <span style={{ paddingLeft: "0.5em" }}>{day}</span>
+        </div>
+      ))
+    }
+  </div>
+)
+
+const Header = ({ name }) => (
+  <>
+    <span>Ignore how ugly this site is I wrote it in a total of 10 hours</span>
+    <span>Logged in as: {name}</span>
+    <span>Green is definitely available</span>
+    <span>Red is definitely not available</span>
+    <span>Yellow is possibly available</span>
+    <span>White is unknown availability</span>
+    <span>WEEK buttons toggle a whole week</span>
+    <DaysOfWeek />
+  </>
+)
+
+const Week = ({ days, user, change }) => (
+  <div
+    style={{
+      display: "flex",
+      flexFlow: "row nowrap",
+      alignItems: "center",
+    }}
+  >
+    <div 
+      style={{
+        display: "flex",
+        flexFlow: "row nowrap",
+      }}
+    >
+      {
+        days.map(item => (
+          <Tile
+            key={item.day} 
+            user={user}
+            month={item.month}
+            day={item.day}
+            availability={item.availability}
+            onClick={(month, day, status) => change(user, [{ month, day, status }])}
+          />
+        ))
+      }
+    </div>
+    <div
+      onClick={() => change(user, days.map(({ month, day, availability }) => ({
+        month,
+        day,
+        status: getNext(user, availability),
+      })))}
+      style={{
+        paddingLeft: "1em",
+      }}
+    >
+      WEEK
+    </div>
+  </div>
+)
+
+const useCalendar = () => {
   const [calendar, setCalendar] = useState(null);
   const lastUpdatedRef = useRef(null);
-
-  const updateCalendar = async () => {
+  const updateCalendar = useCallback(async () => {
     const { availability, lastUpdated } = await getAvailability();
     if (lastUpdated !== lastUpdatedRef.current) {
       // only actually update if there are changes
       lastUpdatedRef.current = lastUpdated;
-      setCalendar(availability);
+      const newCalendar = new Map();
+      availability.forEach(({ month, day, availability: availabilityList }) => {
+        newCalendar.set(`${month}-${day}`, availabilityList);
+      });
+      setCalendar(newCalendar);
     }
-  };
-
-  const advance = async (dates) => {
-    const updates = dates.map(({ month, day }) => {
-      const currentStatus = calendar
-        .find(item => item.month === month && item.day === day)
-        ?.availability
-        ?.find(item => item.name === name)
-        ?.status 
-        ?? "unknown"
-      const status = nextState[currentStatus] || "unknown";
-      return { month, day, status }
-    });
-    const { lastUpdated, availability } = await setAvailability(name, updates);
-    lastUpdatedRef.current = lastUpdated;
-    setCalendar(availability);
-  };
-  
-  useEffect(() => { 
-    const interval = setInterval(updateCalendar, 5000); // update every few seconds
-
-    updateCalendar(); // do an update to get started
-
-    return () => clearInterval(interval); // cleanup if we unmount
-  }, []);
-
-
-  if (name === null) {
-    return <NameForm onNameSet={setName} />
-  }
+  }, [lastUpdatedRef]);
 
-  if (calendar === null) {
-    return <span>Loading...</span>
+  useEffect(() => {
+    // do the first update
+    updateCalendar();
+    // update every few seconds
+    const interval = setInterval(updateCalendar, 5000);
+    return () => { clearInterval(interval); };
+  }, [updateCalendar]);
+
+  const getter = (month, day) => calendar?.get(`${month}-${day}`) ?? [];
+
+  const change = useCallback(async (name, updates) => {
+    await setAvailability(name, updates);
+    await updateCalendar();
+  }, [updateCalendar]);
+
+  return [getter, change];
+}
+
+function App() {
+  const [name, setName] = useState(null);
+  const [getDay, change] = useCalendar();
+
+  if (name === null) { 
+    return <NameForm onNameSet={setName} />; 
   }
 
+
   return (
     <div
       style={{
@@ -148,72 +238,16 @@ function App() {
         flexFlow: "column nowrap",
       }}
     >
-      <span>Ignore how ugly this site is I wrote it in a total of 10 hours</span>
-      <span>Logged in as: {name}</span>
-      <span>Green is definitely available</span>
-      <span>Red is definitely not available</span>
-      <span>Yellow is possibly available</span>
-      <span>White is unknown availability</span>
-      <span>WEEK buttons toggle a whole week</span>
-      <div
-        style={{
-          display: "flex",
-          flexFlow: "row nowrap",
-        }}
-      >
-        {
-          ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
-          .map(day => (
-            <div
-              style={{
-                width: "10em",
-                height: "1.5em",
-                border: "1px solid black",
-                margin: "2px 2px 2px 2px",
-              }}
-            >
-              <span style={{ paddingLeft: "0.5em" }}>{day}</span>
-            </div>
-          ))
-        }
-      </div>
+      <Header name={name} />
       {
-        chunk(calendar, 7).map((row, ind) => (
-          <div
-            key={ind} 
-            style={{
-              display: "flex",
-              flexFlow: "row nowrap",
-              alignItems: "center",
-            }}
-          >
-            <div 
-              style={{
-                display: "flex",
-                flexFlow: "row nowrap",
-              }}
-            >
-              {
-                row.map(item => (
-                  <Tile
-                    onClick={() => advance([item])}
-                    key={item.day} 
-                    user={name} 
-                    {...item}
-                  />
-                ))
-              }
-            </div>
-            <div
-              onClick={() => advance(row)}
-              style={{
-                paddingLeft: "1em",
-              }}
-            >
-              WEEK
-            </div>
-          </div>
-        ))
+        chunk(DATES, 7).map(days => 
+          <Week 
+            key={`${days[0].month}-${days[0].day}`} 
+            days={days.map(({ month, day }) => ({ month, day, availability: getDay(month, day) }))} 
+            change={change}
+            user={name}
+          />
+        )
       }
     </div>
   );

+ 1 - 3
client/src/api.js

@@ -1,4 +1,4 @@
-const BASE_URL = "https://kirkleon.ddns.net/vacation-planner/api"
+const BASE_URL = "http://localhost:8000"
 
 export const getHealth = async () => {
   const res = await fetch(BASE_URL + "/");
@@ -33,6 +33,4 @@ export const setAvailability = async (name, availability) => {
   if (!res.ok) {
     throw new Error(res.statusText);
   }
-
-  return await res.json();
 }

File diff suppressed because it is too large
+ 977 - 658
client/yarn.lock


+ 0 - 17
deploy.sh

@@ -1,17 +0,0 @@
-#!/usr/bin/env sh
-
-pushd client/
-yarn build
-popd
-
-pushd server/
-./gendb.py 5/31 9/5 vacation.db
-popd
-
-tar czvf /tmp/deploy-vp.tgz \
-    server/app.py \
-    server/requirements.txt \
-    server/vacation.db \
-    client/build
-
-scp /tmp/deploy-vp.tgz hiram:/opt/vacation-planner/deploy-vp.tgz

+ 18 - 0
server/Dockerfile

@@ -0,0 +1,18 @@
+FROM python:3.9
+
+LABEL maintainer="Kirk Trombley <ktrom3894@gmail.com>"
+
+EXPOSE 5000
+
+WORKDIR /app/
+
+COPY requirements.txt ./
+RUN pip3 install --upgrade pip && \
+    pip3 install -r requirements.txt && \
+    rm requirements.txt
+
+ENV DB_LOCATION="/app/vacation.db"
+
+COPY app.py .
+
+CMD uvicorn app:app --host 0.0.0.0 --port 5000 --root-path /vacation-planner/api

+ 81 - 77
server/app.py

@@ -1,74 +1,57 @@
-#!/usr/bin/env python3
-
-import pickle
-import atexit
+import os
 import datetime
-
-from flask import Flask, Blueprint, jsonify, request, abort
-from flask_cors import CORS
-
-DB_FILE = "vacation.db"
-
-last_update = datetime.datetime.now()
-since_backup = 0
-
-try:
-    with open(DB_FILE, "rb") as infile:
-        db = pickle.load(infile)
-except FileNotFoundError:
-    db = {}
-
-
-@atexit.register
-def save_db(prefix=""):
-    with open(prefix + DB_FILE, "wb") as outfile:
-        pickle.dump(db, outfile)
-
-
-def backup_db():
-    save_db(f"backup-{datetime.datetime.now().isoformat()}-")
-
-
-bp = Blueprint("vacation", __name__)
+import itertools
+from typing import List, Union
+from enum import Enum
+
+from fastapi import FastAPI, status
+from fastapi.middleware.cors import CORSMiddleware
+from pydantic import BaseModel
+import aiosqlite
+
+DB_FILE = os.environ.get("DB_LOCATION", "vacation.db")
+LAST_UPDATE = datetime.datetime.now()
+
+app = FastAPI()
+app.add_middleware(
+    CORSMiddleware,     
+    allow_origins=["*"],
+    allow_credentials=False,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+
+def connect():
+    return aiosqlite.connect(DB_FILE)
+
+
+@app.on_event("startup")
+async def setup():
+    async with connect() as db:
+        await db.execute("CREATE TABLE IF NOT EXISTS availability ( \
+            name TEXT NOT NULL, \
+            month INTEGER NOT NULL, \
+            day INTEGER NOT NULL, \
+            status TEXT CHECK(status = 'yes' OR status = 'no' OR status = 'maybe' OR status = 'unknown') DEFAULT 'unknown', \
+            PRIMARY KEY (name, month, day)\
+        );")
+        await db.commit()
+
+
+@app.get("/")
+def health():
+    return {"status": "healthy"}
 
 
-@bp.route("/")
-def health():
-    return jsonify({"status": "healthy"})
-
-
-@bp.route("/availability", methods=["GET", "POST"])
-def status():
-    global last_update, since_backup
-    
-    if request.method == "POST":
-        body = request.get_json()
-        if body is None:
-            abort(400)
-        name = body.get("name", None)
-        availability = body.get("availability", None)
-        if not isinstance(name, str) or not isinstance(availability, list):
-            abort(400)
-        
-        transaction = []
-        for a in availability:
-            month = a.get("month", None)
-            day = a.get("day", None)
-            status = a.get("status", None)
-            if (month, day) not in db or status not in ("yes", "no", "maybe", "unknown"):
-                abort(400)
-            transaction.append((month, day, status))
-        
-        last_update = datetime.datetime.now()
-        for (month, day, status) in transaction:
-            db[(month, day)][name] = status
-
-        since_backup += 1
-        if since_backup > 50:
-            backup_db()
-
-    return jsonify({
-        "lastUpdated": last_update.isoformat(),
+@app.get("/availability")
+async def get_all():
+    results = []
+    async with connect() as db:
+        async with db.execute("SELECT name, month, day, status FROM availability ORDER BY month, day;") as cursor:
+            results = await cursor.fetchall()
+    return {
+        "lastUpdated": LAST_UPDATE.isoformat(),
         "availability": [
             {
                 "month": month,
@@ -76,17 +59,38 @@ def status():
                 "availability": [{
                     "name": name,
                     "status": status,
-                } for (name, status) in avail.items()],
-            } for (month, day), avail in db.items()
+                } for (name, _, _, status) in avail],
+            } for ((month, day), avail) in itertools.groupby(results, key=lambda t: (t[1], t[2]))
         ],
-    })
+    }
+
+
+class AvailabilityStatus(Enum):
+    yes = "yes"
+    maybe = "maybe"
+    no = "no"
+    unknown = "unknown"
+
+
+class AvailabilityItem(BaseModel):
+    month: int
+    day: int
+    status: Union[AvailabilityStatus, None]
 
 
-app = Flask(__name__)
-CORS(app)
-app.register_blueprint(bp, url_prefix="/vacation-planner/api")
-app.url_map.strict_slashes = False
+class Availability(BaseModel):
+    name: str
+    availability: List[AvailabilityItem]
 
 
-if __name__ == "__main__":
-    app.run("0.0.0.0", 5000, debug=False, threaded=True)
+@app.post("/availability", status_code=status.HTTP_204_NO_CONTENT)
+async def set_availability(body: Availability):
+    global LAST_UPDATE
+    async with connect() as db:
+        for a in body.availability:
+            await db.execute(
+                "INSERT INTO availability(name, month, day, status) VALUES (?, ?, ?, ?) ON CONFLICT DO UPDATE SET name=?, month=?, day=?, status=?", 
+                (body.name, a.month, a.day, a.status.value, body.name, a.month, a.day, a.status.value),
+            )
+        await db.commit()
+    LAST_UPDATE = datetime.datetime.now()

+ 0 - 22
server/gendb.py

@@ -1,22 +0,0 @@
-#!/usr/bin/env python3
-
-import sys
-import pickle
-from datetime import date, timedelta
-
-
-def days_between(start, end):
-    return [start + timedelta(i) for i in range((end - start).days + 1)]
-        
-
-def build_dict(days):
-    return {(d.month, d.day): {} for d in days}
-
-
-def parse_arg(arg):
-    return date(2020, *[int(x) for x in arg.split("/")])
-
-
-if __name__ == "__main__":
-    with open(sys.argv[3], "wb") as outfile:
-        pickle.dump(build_dict(days_between(parse_arg(sys.argv[1]), parse_arg(sys.argv[2]))), outfile)

+ 8 - 8
server/requirements.txt

@@ -1,8 +1,8 @@
-Click==7.0
-Flask==1.1.1
-Flask-Cors==3.0.8
-itsdangerous==1.1.0
-Jinja2==2.11.1
-MarkupSafe==1.1.1
-six==1.14.0
-Werkzeug==1.0.0
+aiosqlite==0.17.0
+click==7.1.2
+fastapi==0.63.0
+h11==0.12.0
+pydantic==1.8.1
+starlette==0.13.6
+typing-extensions==3.7.4.3
+uvicorn==0.13.4

Some files were not shown because too many files changed in this diff