initial commit

This commit is contained in:
jomo 2022-08-04 05:41:51 +02:00
commit 0961d0e47d
15 changed files with 564 additions and 0 deletions

6
.dockerignore Normal file
View file

@ -0,0 +1,6 @@
/.dockerignore
/.gitignore
/inventory.sqlite3
/README.md
*.pyc
Dockerfile

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/inventory.sqlite3
*.pyc

10
Dockerfile Normal file
View file

@ -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

25
README.md Normal file
View file

@ -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.

20
crud.py Normal file
View file

@ -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()

14
database.py Normal file
View file

@ -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()

47
main.py Normal file
View file

@ -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")

16
models.py Normal file
View file

@ -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)

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
fastapi>=0.79.0
sqlalchemy>=1.4.39
uvicorn>=0.17.6

16
schemas.py Normal file
View file

@ -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

44
static/form.html Normal file
View file

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html>
<head>
<title>in?</title>
<link rel="stylesheet" href="style.css">
<script src="form.js"></script>
</head>
<body>
<h1><a href="../">in?</a></h1>
<form id="form">
<label for="id">ID</label>
<input id="id" type="text" placeholder="loading...">
<label for="is_in">Is in</label>
<select id="is_in">
<option value=""></option>
</select>
<label for="coords_bl">Bottom left coordinates</label>
<input id="coords_bl" type="text">
<label for="coords_tr">Top right coordinates</label>
<input id="coords_tr" type="text">
<label for="type">Type</label>
<input id="type" type="text">
<label for="name">Name / Label</label>
<input id="name" type="text">
<label for="content">Content</label>
<textarea id="content"></textarea>
<label for="note">Note</label>
<textarea id="note"></textarea>
<label for="hidden">Hide by default</label>
<input type="checkbox" id="hidden">
</form>
<button id="delete" class="btn red" autocomplete="off">Delete</button>
<button id="save" class="btn green" autocomplete="off">Save</button>
</body>
</html>

103
static/form.js Normal file
View file

@ -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}`);
});
}
}

30
static/index.html Normal file
View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html>
<head>
<title>in?</title>
<link rel="stylesheet" href="style.css">
<script src="index.js"></script>
</head>
<body>
<h1>in?</h1>
<input type="text" id="search" value="" autocomplete="off" placeholder="RegEx search" tabindex="1" autofocus>
<label>Show hidden items</label> <input type="checkbox" id="showhidden">
<div id="results">
<span id="loading">loading…</span>
<template id="item">
<div id="" class="result" tabindex="0">
<h2>
<span class="id"></span>
<span class="name"></span>
<a title="edit"></a>
<span class="loc"></span>
</h2>
<div class="type"></div>
<div class="note"></div>
<div class="content"></div>
</div>
</template>
</div>
<a href="form.html" class="btn green">+</a>
</body>
</html>

92
static/index.js Normal file
View file

@ -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');
}
}

136
static/style.css Normal file
View file

@ -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;
}