From 0961d0e47d20cf84d596297e3cb326ab60ea4e1f Mon Sep 17 00:00:00 2001 From: jomo Date: Thu, 4 Aug 2022 05:41:51 +0200 Subject: [PATCH] initial commit --- .dockerignore | 6 ++ .gitignore | 2 + Dockerfile | 10 ++++ README.md | 25 +++++++++ crud.py | 20 +++++++ database.py | 14 +++++ main.py | 47 ++++++++++++++++ models.py | 16 ++++++ requirements.txt | 3 + schemas.py | 16 ++++++ static/form.html | 44 +++++++++++++++ static/form.js | 103 +++++++++++++++++++++++++++++++++++ static/index.html | 30 ++++++++++ static/index.js | 92 +++++++++++++++++++++++++++++++ static/style.css | 136 ++++++++++++++++++++++++++++++++++++++++++++++ 15 files changed, 564 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 crud.py create mode 100644 database.py create mode 100644 main.py create mode 100644 models.py create mode 100644 requirements.txt create mode 100644 schemas.py create mode 100644 static/form.html create mode 100644 static/form.js create mode 100644 static/index.html create mode 100644 static/index.js create mode 100644 static/style.css diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0fdd12c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +/.dockerignore +/.gitignore +/inventory.sqlite3 +/README.md +*.pyc +Dockerfile diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53c9ebf --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/inventory.sqlite3 +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..228fbb6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.10-alpine + +WORKDIR /app + +COPY requirements.txt . +RUN python -m pip install -r requirements.txt +COPY . . + +ENTRYPOINT [ "uvicorn", "main:app", "--use-colors", "--host", "0.0.0.0" ] +EXPOSE 8000 diff --git a/README.md b/README.md new file mode 100644 index 0000000..208c95d --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# in? + +A simple inventory system. + +It uses `fastapi`, `sqlalchemy` and `sqlite`. + +## Run + +```sh +docker build -t in . +docker run -p 127.0.0.1:8000:8000 -it in +``` + +Then visit http://127.0.0.1:8000/. + +The database lives at `inventory.sqlite3` (at `/app/` inside the container). + +## Development + +```sh +python -m pip install -r requirements.txt +uvicorn main:app --reload +``` + +API documentation is generated at http://127.0.0.1:8000/docs or http://127.0.0.1:8000/redoc. diff --git a/crud.py b/crud.py new file mode 100644 index 0000000..881d50c --- /dev/null +++ b/crud.py @@ -0,0 +1,20 @@ +from sqlalchemy.orm import Session +from sqlalchemy import DECIMAL, cast + +import models, schemas + +def get_items(db: Session) -> list[models.Item]: + return db.query(models.Item).order_by(models.Item.type, cast(models.Item.id, DECIMAL)) + + +def put_item(db: Session, id: str, item: schemas.Item): + updated = bool(db.query(models.Item).filter(models.Item.id == id).update(item.dict())) + if not updated: + db.add(models.Item(**item.dict())) + db.commit() + return updated + + +def delete_item(db: Session, id: str): + db.query(models.Item).filter(models.Item.id == id).delete() + db.commit() diff --git a/database.py b/database.py new file mode 100644 index 0000000..ac9f8a6 --- /dev/null +++ b/database.py @@ -0,0 +1,14 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +SQLALCHEMY_DATABASE_URL = "sqlite:///./inventory.sqlite3" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args = {"check_same_thread": False}, + echo=True +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() diff --git a/main.py b/main.py new file mode 100644 index 0000000..c52f809 --- /dev/null +++ b/main.py @@ -0,0 +1,47 @@ +from fastapi import Depends, FastAPI, Response +from fastapi.staticfiles import StaticFiles +from sqlalchemy import event +from sqlalchemy.orm import Session +from sqlalchemy.engine import Engine + +import crud +from database import SessionLocal, engine +from models import Base +from schemas import Item + +Base.metadata.create_all(bind=engine) + +app = FastAPI() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +@event.listens_for(Engine, "connect") +def _set_sqlite_pragma(conn, _): + cursor = conn.cursor() + cursor.execute("PRAGMA foreign_keys=ON;") + cursor.close() + + +@app.get("/api/items", response_model=dict[str, Item]) +async def list_items(db: Session = Depends(get_db)): + items = crud.get_items(db) + return {i.id:i for i in items} + + +@app.put("/api/items/{id}") +async def put_item(id: str, item: Item, db: Session = Depends(get_db)): + if crud.put_item(db, id, item): + return Response(b'', status_code=204) + return Response(b'', status_code=201) + + +@app.delete("/api/items/{id}", status_code=204) +async def delete_item(id: str, db: Session = Depends(get_db)): + crud.delete_item(db, id) + +app.mount("/", StaticFiles(directory="static", html=True), name="static") diff --git a/models.py b/models.py new file mode 100644 index 0000000..d7618a6 --- /dev/null +++ b/models.py @@ -0,0 +1,16 @@ +from sqlalchemy import Column, ForeignKey, String, Text, Boolean + +from database import Base + +class Item(Base): + __tablename__ = "items" + + id = Column(String, primary_key=True, index=True) + is_in = Column(String, ForeignKey(id, onupdate="CASCADE", ondelete="SET NULL")) + coords_bl = Column(String) + coords_tr = Column(String) + type = Column(String) + name = Column(String) + content = Column(Text) + note = Column(Text) + hidden = Column(Boolean, nullable=False) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..54b2b3d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.79.0 +sqlalchemy>=1.4.39 +uvicorn>=0.17.6 diff --git a/schemas.py b/schemas.py new file mode 100644 index 0000000..218fe94 --- /dev/null +++ b/schemas.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + + +class Item(BaseModel): + id: str + is_in: str | None + coords_bl: str | None + coords_tr: str | None + type: str | None + name: str | None + content: str | None + note: str | None + hidden: bool + + class Config: + orm_mode = True diff --git a/static/form.html b/static/form.html new file mode 100644 index 0000000..ab29a11 --- /dev/null +++ b/static/form.html @@ -0,0 +1,44 @@ + + + + in? + + + + +

in?

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + diff --git a/static/form.js b/static/form.js new file mode 100644 index 0000000..22ae40b --- /dev/null +++ b/static/form.js @@ -0,0 +1,103 @@ +let items = {}; +const id = new URLSearchParams(window.location.search).get('id'); + +document.onreadystatechange = function() { + if (document.readyState === 'interactive') { + fetch('/api/items').then(r => r.json()).then((data) => { + items = data; + fillForm(); + }); + } +} + +function fillForm() { + const item = items[id]; + + const is_in = document.getElementById('is_in'); + + for (const [_, item] of Object.entries(items)) { + const option = document.createElement('option'); + option.value = item.id; + option.textContent = `${item.id} ${item.name && `(${item.name})` || ''}` + is_in.appendChild(option); + }; + + document.getElementById('id').placeholder = ''; + + if (id && item) { + document.getElementById('id').value = item.id; + document.getElementById('is_in').value = item.is_in; + document.getElementById('coords_bl').value = item.coords_bl; + document.getElementById('coords_tr').value = item.coords_tr; + document.getElementById('type').value = item.type; + document.getElementById('name').value = item.name; + document.getElementById('content').value = item.content; + document.getElementById('note').value = item.note; + document.getElementById('hidden').checked = item.hidden; + } + + const saveBtn = document.getElementById('save'); + const delBtn = document.getElementById('delete'); + + saveBtn.onclick = function(e) { + e.preventDefault(); + save(); + }; + + delBtn.onclick = function(e) { + e.preventDefault(); + del(); + } +} + +function save() { + item = { + id: document.getElementById('id').value || null, + is_in: document.getElementById('is_in').value || null, + coords_bl: document.getElementById('coords_bl').value || null, + coords_tr: document.getElementById('coords_tr').value || null, + type: document.getElementById('type').value || null, + name: document.getElementById('name').value || null, + content: document.getElementById('content').value || null, + note: document.getElementById('note').value || null, + hidden: document.getElementById('hidden').checked, + } + + if (items[item.id]) { + if (!id || (id && id != item.id)) { + alert(`ID ${item.id} exists. Please chose a different ID or edit the existing ${item.id} item!`); + return; + } + } + + const original_id = id || item.id; + + fetch(`/api/items/${original_id}`, { + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(item), + }) + .then(r => { + if (r.ok) { + window.location.href = '../'; + } else { + r.text().then(t => alert(`Error:\n${t}`)); + } + }).catch(error => { + alert(`Error:\n${error}`); + }); +} + +function del() { + if (id && confirm('Are you sure?')) { + fetch(`/api/items/${id}`, {method: 'DELETE'}).then(r => { + if (r.ok) { + window.location.href = '../'; + } else { + r.text().then(t => alert(`Error:\n${t}`)); + } + }).catch(error => { + alert(`Error:\n${error}`); + }); + } +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..2222ab6 --- /dev/null +++ b/static/index.html @@ -0,0 +1,30 @@ + + + + in? + + + + +

in?

+ + +
+ loading… + +
+ + + + diff --git a/static/index.js b/static/index.js new file mode 100644 index 0000000..3e40dd6 --- /dev/null +++ b/static/index.js @@ -0,0 +1,92 @@ +let items = {}; + +document.onreadystatechange = function() { + if (document.readyState === 'interactive') { + document.getElementById('search').oninput = search; + const checkbox = document.getElementById('showhidden') + checkbox.oninput = showhidden; + fetch('/api/items').then((response) => response.json()).then((data) => { + items = data; + renderItems(); + showhidden({target: checkbox}); + }); + } +} + +function renderItems() { + const container = document.getElementById('results'); + const template = document.getElementById('item'); + const loading = document.getElementById('loading'); + + for (const [id, item] of Object.entries(items)) { + const clone = template.content.cloneNode(true); + const [loc, longloc] = getLocString(items, item); + clone.querySelector(".loc").textContent = loc; + clone.querySelector(".loc").title = longloc; + clone.querySelector(".type").textContent = item.type; + clone.querySelector(".id").textContent = item.id; + clone.querySelector(".name").textContent = item.name; + clone.querySelector(".content").textContent = item.content; + clone.querySelector(".note").textContent = item.note; + clone.querySelector(".result").id = id; + clone.querySelector("a").href = `form.html?id=${id}`; + if (item.hidden) { + clone.querySelector(".result").classList.add('hidden') + }; + container.appendChild(clone); + }; + loading.remove(); +} + +function getLocString(items, item) { + if (item.is_in == item.id) { + return ['in ↻', `${item.id} is in itself`]; + } + let ancestors = []; + let next = item; + while (next) { + ancestors.unshift(next); + next = items[next.is_in]; + } + ancestors.pop(); + const loc = ancestors.map(i => i.id).join(' ➜ ') || '⬚'; + let longloc = ancestors.map(i => `${i.type || ''} ${i.id} ${i.name && `(${i.name})` || ''}`).join(' ➜ ') || 'universe'; + return [loc, `${item.id} is in ${longloc}`]; +} + +function search(e) { + const searchAttrs = ['id', 'name', 'type', 'note', 'content']; + const query = e.target.value; + const regex = new RegExp(query, 'i') + + for (const elem of document.getElementsByClassName('result')) { + const item = items[elem.id]; + let found = false; + if (query) { + for (const a in searchAttrs) { + const attr = item[searchAttrs[a]]; + if (attr && attr.match(regex)) { + found = true; + break; + } + } + } else { + found = true; + } + + if (found) { + elem.classList.remove('filtered'); + } else { + elem.classList.add('filtered'); + } + } +} + +function showhidden(e){ + const container = document.getElementById('results'); + if (e.target.checked) { + results.classList.add('showhidden'); + } else { + results.classList.remove('showhidden'); + } +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..bdbd379 --- /dev/null +++ b/static/style.css @@ -0,0 +1,136 @@ +* { + box-sizing: border-box; +} + +body { + font-family: sans-serif; + max-width: 600px; + margin: auto; + padding-bottom: 1em; + background: #fefefe; +} + +h1, #search { + text-align: center; + width: 100%; + font-weight: bold; +} + +h1 a { + text-decoration: none; + color: initial; +} + +#results { + margin-top: 1em; +} + +.result { + background: #eee; + padding: 1em; + margin: 1em 0; +} + +.result a { + text-decoration: none; + color: #03d; +} + +.result.hidden { + display: none; +} + +#results.showhidden .result.hidden { + display: block; +} + +.result.filtered { + display: none; +} + +.loc { + float: right; + font-weight: normal; + font-size: 0.8em; +} + +.type { + font-size: 0.8em; +} + +h2 { + margin: 0; + font-size: 20px; +} + +.name { + font-weight: normal; +} + +.content { + margin-top: 1em; +} + +.note:empty { + display: none; +} + +.note { + background: #aef; + padding: 1em; + margin-top: 1em; +} + +.btn { + display: block; + width: 100%; + border: 1px solid; + padding: 0.5em; + text-decoration: initial; + color: initial; + font-weight: bold; + text-align: center; + font-size: 1.5em; + margin-bottom: 0.5em; + cursor: pointer; +} + +.btn.green { + background: #0a0; + color: white; +} + +.btn.red { + background: #a00; + color: white; +} + +form label { + display: block; + width: 100%; + font-weight: bold; + margin-bottom: 0.3em; +} + +input, textarea, select { + display: block; + width: 100%; + max-width: 100%; + min-width: 100%; + margin: 0 0 1em 0; + padding: 0.3em; + font-family: monospace; + font-size: 1.4em; + background: initial; + border: 1px solid gray; +} + +input[type=checkbox] { + width: initial; + display: initial; + min-width: initial; +} + +textarea { + min-height: 8em; +}