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/.forgejo/workflows/deployment-pull-request.yaml b/.forgejo/workflows/deployment-pull-request.yaml new file mode 100644 index 0000000..bef68b9 --- /dev/null +++ b/.forgejo/workflows/deployment-pull-request.yaml @@ -0,0 +1,22 @@ +name: deployment-on-pull-request +on: + pull_request: + pull_request_target: + types: + - opened + - reopened + - synchronize + +jobs: + deployment: + runs-on: docker + container: + image: node:bookworm + steps: + - uses: actions/checkout@v4 + - name: Build inventory + uses: https://codeberg.org/umglurf/kaniko-action@main + with: + cache: false + push: 'false' + context: /workspace/snoopy/in-c4/ diff --git a/.forgejo/workflows/deployment.yaml b/.forgejo/workflows/deployment.yaml new file mode 100644 index 0000000..391e59c --- /dev/null +++ b/.forgejo/workflows/deployment.yaml @@ -0,0 +1,24 @@ +name: deployment +on: + push: + branches: + - 'main' + +jobs: + deployment: + runs-on: docker + container: + image: node:bookworm + steps: + - uses: actions/checkout@v4 + - name: Build inventory + uses: https://codeberg.org/umglurf/kaniko-action@main + with: + cache: true + cache_repo: git.koeln.ccc.de/${{ github.repository }}/cache + push: 'true' + context: /workspace/snoopy/in-c4/ + credentials: | + git.koeln.ccc.de=snoopy:${{ secrets.REGISTRY_WRITE }} + destinations: | + git.koeln.ccc.de/${{ github.repository }}/in-c4:latest diff --git a/.gitignore b/.gitignore index 53c9ebf..03899e1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -/inventory.sqlite3 +/data/* *.pyc 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..7759e10 100644 --- a/database.py +++ b/database.py @@ -2,12 +2,10 @@ from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker -SQLALCHEMY_DATABASE_URL = "sqlite:///./inventory.sqlite3" +SQLALCHEMY_DATABASE_URL = "sqlite:///./data/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/80x80.svg b/static/80x80.svg deleted file mode 100644 index d0124e9..0000000 --- a/static/80x80.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/c4.css b/static/c4.css new file mode 100644 index 0000000..abeae82 --- /dev/null +++ b/static/c4.css @@ -0,0 +1,122 @@ +body { + color: #f0f0f0; + background-color: #333; +} + +h1, h1 a { + color: #dfaa37; /* C4 Yellow */ +} + +#search.failed { + color: #200; + background-color: #ec6d5f; +} + +#map { + border: 1px solid #202020; + background-color: #4d4d4d; + max-width: 1440px; + max-height: 50vh; + aspect-ratio: 2 / 1; + box-shadow: 0px 6px 12px #000c; +} + +#mapnote { + background-color: #ccc; + color: #333; + border-color: #202020; + padding: 0.25em 0.5em; + position: relative; + top: -1px; + box-shadow: 0px 6px 12px #000c; +} + +#mapnote:empty { + display: none; +} + +#itemcount { + display: inline-block; + margin-right: 1ch; +} + +.result { + background-color: #202020; + border-color: #4d4d4d; +} + +.result:focus { + background-color: #202020; + border-color: #dfaa37; + outline-color: #dfaa37; +} + +.result a { + color: #dfaa37; +} + +.note { + background-color: #333; + font-size: smaller; +} + +#knowntypes button { + color: #220; + background-color: #dfaa37; + border: 1px solid #220; + border-radius: 4px; + padding: 0.2em 0.4em; + margin: 0.2em; + font-weight: bolder; + cursor: pointer; +} + +#knowntypes button:hover, #knowntypes button:focus { + background-color: #f5c251; +} + +.btn { + box-shadow: 0 3px 6px -1px rgba(0,0,0,0.3); +} + +.btn.primary { + color: #220; + background-color: #dfaa37; +} + +.btn.primary:hover,.btn.primary:focus { + background-color: #f5c251; +} + +.btn.grn { + color: #020; + background-color: #9f6; +} + +.btn.grn:hover ,.btn.grn:focus { + background-color: #3f0; +} + +.btn.red { + color: #200; + background-color: #ec6d5f; +} + +.btn.red:hover, .btn.red:focus { + background-color: #ff7667; +} + +@keyframes blinker { + 0% { opacity: 1; } + 40% { opacity: 1; } + 90% { opacity: 0; } + 100% { opacity: 1; } +} + +#mapgrid { + outline: 2px solid #333; + background-color: #dfaa37; + border-radius: min(4px, 25%); + animation: blinker 1s infinite; +} + diff --git a/static/form.html b/static/form.html index a5de3f2..44d5c16 100644 --- a/static/form.html +++ b/static/form.html @@ -3,6 +3,7 @@