diff --git a/.dockerignore b/.dockerignore
index 0fdd12c..4bc1268 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,6 +1,6 @@
-/.dockerignore
-/.gitignore
+.*
/inventory.sqlite3
/README.md
*.pyc
Dockerfile
+/contrib/
diff --git a/Dockerfile b/Dockerfile
index 228fbb6..9cfa1aa 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.10-alpine
+FROM python:3.12-alpine3.19
WORKDIR /app
diff --git a/contrib/bulk_create.py b/contrib/bulk_create.py
new file mode 100755
index 0000000..3d7fbf5
--- /dev/null
+++ b/contrib/bulk_create.py
@@ -0,0 +1,47 @@
+#!/usr/bin/env python3
+
+import sys
+
+import requests
+
+BASE = "https://in.ccc.ac"
+COUNT = 0
+START_ID = 1
+
+
+session = requests.Session()
+# session.auth = (":)", ":)")
+
+res = session.get(f"{BASE}/api/items")
+res.raise_for_status()
+ids = res.json().keys()
+kiste_ids = {int(i.lstrip("K")) for i in ids if i.startswith("K") and i != "K"}
+print("existing ids:", repr(kiste_ids), file=sys.stderr)
+
+current_id = START_ID
+new_ids = []
+while len(new_ids) < COUNT:
+ if current_id not in kiste_ids:
+ new_ids.append(current_id)
+ current_id += 1
+
+print("created:", file=sys.stderr)
+if len(new_ids) == 0:
+ print("(none)", file=sys.stderr)
+
+
+for i in new_ids:
+ j = {
+ "id": f"K{i}",
+ "is_in": None,
+ "coords_bl": None,
+ "coords_tr": None,
+ "type": "Kiste",
+ "name": None,
+ "content": None,
+ "note": "Bitte Name, Ort, Inhalt eintragen",
+ "hidden": False,
+ }
+ res = session.put(f"{BASE}/api/items/{j['id']}", json=j)
+ res.raise_for_status()
+ print(f"{j['id']},{BASE}/form.html?id={j['id']}")
diff --git a/crud.py b/crud.py
index f5e1f39..277c881 100644
--- a/crud.py
+++ b/crud.py
@@ -1,18 +1,32 @@
-from sqlalchemy.orm import Session
-from sqlalchemy import DECIMAL, cast
+from enum import Enum
+
+from sqlalchemy import DECIMAL, cast
+from sqlalchemy.orm import Session
+
+import models
+import schemas
-import models, schemas
def get_items(db: Session) -> list[models.Item]:
return db.query(models.Item).all()
def put_item(db: Session, id: str, item: schemas.Item):
- updated = bool(db.query(models.Item).filter(models.Item.id == id).update(item.dict()))
+ 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
+ if updated:
+ return PutItemResult.UPDATED
+ else:
+ return PutItemResult.ADDED
+
+
+class PutItemResult(Enum):
+ ADDED = 1
+ UPDATED = 2
def delete_item(db: Session, id: str):
diff --git a/database.py b/database.py
index ac9f8a6..44a3006 100644
--- a/database.py
+++ b/database.py
@@ -5,9 +5,7 @@ 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
+ SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}, echo=True
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
diff --git a/main.py b/main.py
index ecaeea6..eb66394 100644
--- a/main.py
+++ b/main.py
@@ -1,9 +1,12 @@
+import re
+from calendar import timegm
+from datetime import date
+
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 re
+from sqlalchemy.orm import Session
import crud
from database import SessionLocal, engine
@@ -14,6 +17,7 @@ Base.metadata.create_all(bind=engine)
app = FastAPI()
+
def get_db():
db = SessionLocal()
try:
@@ -21,6 +25,7 @@ def get_db():
finally:
db.close()
+
@event.listens_for(Engine, "connect")
def _set_sqlite_pragma(conn, _):
cursor = conn.cursor()
@@ -31,21 +36,35 @@ def _set_sqlite_pragma(conn, _):
@app.get("/api/items", response_model=dict[str, Item])
async def list_items(db: Session = Depends(get_db)):
# natural sort by id
- natsort = lambda item: [int(t) if t.isdigit() else t.lower() for t in re.split('(\d+)', item.id)]
+ natsort = lambda item: [
+ int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", item.id)
+ ]
items = crud.get_items(db)
items = sorted(items, key=natsort)
- return {i.id:i for i in items}
+ return {i.id: i for i in items}
-@app.put("/api/items/{id}")
+@app.put(
+ "/api/items/{id}",
+ status_code=201,
+ responses={201: {"description": "created"}, 204: {"description": "updated"}},
+)
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)
+ if item.last_updated is None:
+ item.last_updated = month_timestamp()
+ if crud.put_item(db, id, item) == crud.PutItemResult.UPDATED:
+ 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)
+
+def month_timestamp() -> int:
+ """Provides the timestamp of the current month's beginning (for improved privacy)"""
+ return timegm(date.today().replace(day=1).timetuple())
+
+
app.mount("/", StaticFiles(directory="static", html=True), name="static")
diff --git a/models.py b/models.py
index d7618a6..1df2c2b 100644
--- a/models.py
+++ b/models.py
@@ -1,7 +1,8 @@
-from sqlalchemy import Column, ForeignKey, String, Text, Boolean
+from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Text
from database import Base
+
class Item(Base):
__tablename__ = "items"
@@ -14,3 +15,4 @@ class Item(Base):
content = Column(Text)
note = Column(Text)
hidden = Column(Boolean, nullable=False)
+ last_updated = Column(Integer)
diff --git a/requirements.txt b/requirements.txt
index 54b2b3d..92fc06b 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,3 @@
-fastapi>=0.79.0
-sqlalchemy>=1.4.39
-uvicorn>=0.17.6
+fastapi>=0.109.0
+sqlalchemy>=2.0.25
+uvicorn>=0.27.0.post1
diff --git a/schemas.py b/schemas.py
index 218fe94..30f7545 100644
--- a/schemas.py
+++ b/schemas.py
@@ -1,4 +1,4 @@
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
class Item(BaseModel):
@@ -11,6 +11,7 @@ class Item(BaseModel):
content: str | None
note: str | None
hidden: bool
+ last_updated: int | None = Field(None)
class Config:
- orm_mode = True
+ from_attributes = True
diff --git a/static/form.html b/static/form.html
index a5de3f2..d894279 100644
--- a/static/form.html
+++ b/static/form.html
@@ -43,6 +43,9 @@
+
+
+
diff --git a/static/form.js b/static/form.js
index 4ae4968..b69f358 100644
--- a/static/form.js
+++ b/static/form.js
@@ -39,6 +39,7 @@ function fillForm() {
document.getElementById('content').value = item.content;
document.getElementById('note').value = item.note;
document.getElementById('hidden').checked = item.hidden;
+ document.getElementById('last_updated').value = formatTimestamp(item.last_updated);
formCoordsToMap();
}
@@ -172,3 +173,9 @@ function formCoordsToMap() {
coordsToMap(coords_bl, coords_tr);
}
}
+
+function formatTimestamp(ts) {
+ const date = new Date(ts * 1000);
+ // using Swedish format as a hack to get an iso formatted date
+ return date.toLocaleDateString("sv", {timeZone: "UTC"}).replace(/\-\d+$/,'')
+}