initial commit
This commit is contained in:
commit
0961d0e47d
15 changed files with 564 additions and 0 deletions
6
.dockerignore
Normal file
6
.dockerignore
Normal file
|
@ -0,0 +1,6 @@
|
|||
/.dockerignore
|
||||
/.gitignore
|
||||
/inventory.sqlite3
|
||||
/README.md
|
||||
*.pyc
|
||||
Dockerfile
|
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/inventory.sqlite3
|
||||
*.pyc
|
10
Dockerfile
Normal file
10
Dockerfile
Normal 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
25
README.md
Normal 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
20
crud.py
Normal 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
14
database.py
Normal 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
47
main.py
Normal 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
16
models.py
Normal 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
3
requirements.txt
Normal file
|
@ -0,0 +1,3 @@
|
|||
fastapi>=0.79.0
|
||||
sqlalchemy>=1.4.39
|
||||
uvicorn>=0.17.6
|
16
schemas.py
Normal file
16
schemas.py
Normal 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
44
static/form.html
Normal 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
103
static/form.js
Normal 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
30
static/index.html
Normal 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
92
static/index.js
Normal 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
136
static/style.css
Normal 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;
|
||||
}
|
Loading…
Reference in a new issue