commit 0961d0e47d20cf84d596297e3cb326ab60ea4e1f Author: jomo Date: Thu Aug 4 05:41:51 2022 +0200 initial commit 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; +}