Compare commits
3 Commits
main
...
6158f2ddff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6158f2ddff | ||
|
|
fe33d714bd | ||
|
|
6f9c5eb34e |
150
README.md
150
README.md
@@ -1,3 +1,151 @@
|
|||||||
# co_app
|
# co_app
|
||||||
|
|
||||||
Controlling-App
|
## Insallation
|
||||||
|
|
||||||
|
1. Git-Repository clonen
|
||||||
|
2.
|
||||||
|
|
||||||
|
## Entwicklungs- und Produktionsumgebung steuern (APP_ENV)
|
||||||
|
Die App unterscheidet zwischen Entwicklung und Produktion über die Umgebungsvariable APP_ENV.
|
||||||
|
|
||||||
|
### Entwicklung (lokal)
|
||||||
|
Im Terminal setzen:
|
||||||
|
```bash
|
||||||
|
export APP_ENV=dev
|
||||||
|
```
|
||||||
|
Die Variable gilt, solange das Terminal geöffnet ist.
|
||||||
|
Beim Starten der App:
|
||||||
|
```bash
|
||||||
|
streamlit run app/main.py
|
||||||
|
```
|
||||||
|
Die App erkennt automatisch den Dev-Modus.
|
||||||
|
|
||||||
|
### Produktion (Systemd-Dienst)
|
||||||
|
|
||||||
|
Für den produktiven Betrieb setzt systemd die Variable dauerhaft auf prod:
|
||||||
|
```ini
|
||||||
|
Environment=APP_ENV=prod
|
||||||
|
```
|
||||||
|
In prod-Modus laufen z. B. Logging und Pfade anders als lokal.
|
||||||
|
|
||||||
|
### systemd-Dienst einrichten
|
||||||
|
``/etc/systemd/system/myapp.service``
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=MyApp Streamlit Service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=co_app
|
||||||
|
Group=co_app
|
||||||
|
WorkingDirectory=/opt/co_app
|
||||||
|
Environment=APP_ENV=prod
|
||||||
|
ExecStart=/usr/bin/python3 -m streamlit run app/main.py --server.port=8501
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
**Installation & Start**
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable co_app.service
|
||||||
|
sudo systemctl start co_app.service
|
||||||
|
```
|
||||||
|
**Logs ansehen**
|
||||||
|
Da streamlit + python ins systemd-Journal loggen:
|
||||||
|
```bash
|
||||||
|
journalctl -u co_app.service -f
|
||||||
|
```
|
||||||
|
**Wichtig**
|
||||||
|
- APP_ENV=dev → Entwicklung, lokale Logs, lokale DB
|
||||||
|
- APP_ENV=prod → Systemd-Betrieb, serverseitige Pfade, Logging ins Journal
|
||||||
|
- Die App liest die Variable mit:
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
APP_ENV = os.environ.get("APP_ENV", "dev")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Setup & Migration Guide
|
||||||
|
|
||||||
|
Dieses Projekt verwendet SQLite für die interne App-Datenbank.
|
||||||
|
Das Schema wird vollständig über SQL-Migrationsdateien verwaltet, die automatisch in der richtigen Reihenfolge ausgeführt werden.
|
||||||
|
|
||||||
|
### Ordnerstruktur
|
||||||
|
|
||||||
|
```bash
|
||||||
|
data/ ← enthält die erzeugte SQLite-Datenbank (app.db)
|
||||||
|
migrations/ ← enthält alle SQL-Migrationsdateien
|
||||||
|
migrate.py ← Python-Skript zum Anwenden der Migrationen
|
||||||
|
```
|
||||||
|
### Erstellen der Datenbank (Initial Setup)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python migrate.py
|
||||||
|
```
|
||||||
|
Das Skript:
|
||||||
|
|
||||||
|
1. erstellt den Ordner db/ (falls nicht vorhanden)
|
||||||
|
2. erzeugt eine neue Datei db/app.db
|
||||||
|
3. führt alle SQL-Dateien im Ordner migrations/ der Reihe nach aus
|
||||||
|
4. protokolliert die angewendeten Migrationen in der Tabelle schema_version
|
||||||
|
|
||||||
|
Danach ist die Datenbank vollständig initialisiert und einsatzbereit.
|
||||||
|
|
||||||
|
### Arbeiten mit Migrationen (Änderungen am Schema)
|
||||||
|
|
||||||
|
Änderungen an der Datenbankstruktur werden nie direkt an der DB vorgenommen.
|
||||||
|
Stattdessen erzeugst du eine neue Migrationsdatei im Ordner migrations/.
|
||||||
|
|
||||||
|
**Beispiel: neue Spalte hinzufügen**
|
||||||
|
|
||||||
|
Lege eine Datei an
|
||||||
|
|
||||||
|
```bash
|
||||||
|
touch /migrations/20250301_101500_add_last_login.sql
|
||||||
|
```
|
||||||
|
Dateiinhalt
|
||||||
|
```sql
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN last_login DATETIME;
|
||||||
|
|
||||||
|
INSERT INTO schema_version (version)
|
||||||
|
VALUES ('20250301_101500_add_last_login');
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
```
|
||||||
|
Migration anwenden
|
||||||
|
```bash
|
||||||
|
python migrate.py
|
||||||
|
```
|
||||||
|
Das Skript erkennt automatisch, dass diese Migration neu ist und führt sie aus.
|
||||||
|
|
||||||
|
### Erneutes Ausführen (Updates)
|
||||||
|
Wenn eine bestehende Installation aktualisiert werden soll:
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
python migrate.py
|
||||||
|
```
|
||||||
|
Das Skript:
|
||||||
|
|
||||||
|
- liest die Tabelle schema_version
|
||||||
|
- führt nur Migrationen aus, die noch nicht angewendet wurden
|
||||||
|
|
||||||
|
Bestehende Strukturen bleiben unberührt.
|
||||||
|
|
||||||
|
### Entwicklungsphase: Hinweise
|
||||||
|
|
||||||
|
Solange das Projekt noch nicht produktiv genutzt wird, gilt:
|
||||||
|
- du kannst Migrationsdateien löschen/zusammenführen
|
||||||
|
- du kannst die komplette app.db jederzeit löschen
|
||||||
|
- vor dem ersten echten Deploy kannst du alle Migrationen zu einer sauberen initial.sql zusammenfassen
|
||||||
|
|
||||||
|
👉 **Nach dem ersten produktiven Einsatz** werden Migrationen nie mehr gelöscht, sondern nur ergänzt.
|
||||||
|
|
||||||
|
### Optional: DB neu erzeugen für Tests
|
||||||
|
Um eine frische Datenbank zu bekommen:
|
||||||
|
```bash
|
||||||
|
rm -f data/app.db
|
||||||
|
python migrate.py
|
||||||
|
```
|
||||||
|
|||||||
93
app/auth.py
Normal file
93
app/auth.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import sqlite3
|
||||||
|
from contextlib import closing
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
from db import get_conn
|
||||||
|
|
||||||
|
def create_user(username: str, password: str, role: str = "user") -> bool:
|
||||||
|
pw_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
|
||||||
|
try:
|
||||||
|
with closing(get_conn()) as conn, conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)",
|
||||||
|
(username, pw_hash, role),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def verify_user(username: str, password: str):
|
||||||
|
with closing(get_conn()) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT password_hash, role FROM users WHERE username = ?",
|
||||||
|
(username,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return False, None
|
||||||
|
stored_hash, role = row
|
||||||
|
ok = bcrypt.checkpw(password.encode("utf-8"), stored_hash)
|
||||||
|
return (ok, role) if ok else (False, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Session / Helper ----------
|
||||||
|
def set_session_user(username: str, role: str):
|
||||||
|
st.session_state["auth"] = True
|
||||||
|
st.session_state["username"] = username
|
||||||
|
st.session_state["role"] = role
|
||||||
|
|
||||||
|
|
||||||
|
def clear_session_user():
|
||||||
|
for k in ["auth", "username", "role"]:
|
||||||
|
st.session_state.pop(k, None)
|
||||||
|
|
||||||
|
|
||||||
|
def is_authenticated() -> bool:
|
||||||
|
return bool(st.session_state.get("auth"))
|
||||||
|
|
||||||
|
|
||||||
|
def current_user():
|
||||||
|
"""(username, role) oder (None, None)"""
|
||||||
|
return st.session_state.get("username"), st.session_state.get("role")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_logged_in():
|
||||||
|
"""
|
||||||
|
Am Anfang jeder Page aufrufen.
|
||||||
|
Wenn kein Login: Login-View anzeigen und Script stoppen.
|
||||||
|
"""
|
||||||
|
if not is_authenticated():
|
||||||
|
login_view()
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- UI ----------
|
||||||
|
def login_view():
|
||||||
|
st.title("Intranet Login")
|
||||||
|
|
||||||
|
with st.form("login"):
|
||||||
|
u = st.text_input("Username")
|
||||||
|
p = st.text_input("Passwort", type="password")
|
||||||
|
submitted = st.form_submit_button("Anmelden")
|
||||||
|
|
||||||
|
if submitted:
|
||||||
|
ok, role = verify_user(u.strip(), p)
|
||||||
|
if ok:
|
||||||
|
set_session_user(u.strip(), role)
|
||||||
|
st.success("Erfolgreich angemeldet.")
|
||||||
|
st.rerun()
|
||||||
|
else:
|
||||||
|
st.error("Login fehlgeschlagen.")
|
||||||
|
|
||||||
|
|
||||||
|
def authed_header():
|
||||||
|
username, role = current_user()
|
||||||
|
if not username:
|
||||||
|
return
|
||||||
|
|
||||||
|
st.sidebar.write(f"Angemeldet als **{username}** ({role})")
|
||||||
|
if st.sidebar.button("Logout"):
|
||||||
|
clear_session_user()
|
||||||
|
st.rerun()
|
||||||
10
app/db.py
Normal file
10
app/db.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
DB_PATH = BASE_DIR / "data" / "app.db"
|
||||||
|
|
||||||
|
|
||||||
|
def get_conn():
|
||||||
|
# check_same_thread=False, damit Streamlit mehrere Threads nutzen kann
|
||||||
|
return sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||||
24
app/logging_config.py
Normal file
24
app/logging_config.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def setup_logging(app_env: str):
|
||||||
|
logger = logging.getLogger()
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
|
||||||
|
|
||||||
|
# Always log to console (terminal / systemd)
|
||||||
|
sh = logging.StreamHandler()
|
||||||
|
sh.setFormatter(fmt)
|
||||||
|
logger.addHandler(sh)
|
||||||
|
|
||||||
|
# Dev → additional file logging
|
||||||
|
if app_env == "dev":
|
||||||
|
log_dir = Path("logs")
|
||||||
|
log_dir.mkdir(exist_ok=True)
|
||||||
|
fh = logging.FileHandler(log_dir / "app.log", encoding="utf-8")
|
||||||
|
fh.setFormatter(fmt)
|
||||||
|
logger.addHandler(fh)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
10
app/main.py
Normal file
10
app/main.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from version import __version__
|
||||||
|
from logging_config import setup_logging
|
||||||
|
import os
|
||||||
|
from migrate import apply_migrations
|
||||||
|
|
||||||
|
APP_ENV = os.environ.get("APP_ENV", "dev")
|
||||||
|
logger = setup_logging(APP_ENV)
|
||||||
|
logger.info(f"Starting app in {APP_ENV} mode - APP-Version {__version__}")
|
||||||
|
|
||||||
|
apply_migrations()
|
||||||
84
app/migrate.py
Normal file
84
app/migrate.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import sqlite3
|
||||||
|
from version import __version__
|
||||||
|
from pathlib import Path
|
||||||
|
import logging
|
||||||
|
from logging_config import setup_logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
APP_ENV = os.environ.get("APP_ENV", "dev")
|
||||||
|
logger = setup_logging(APP_ENV)
|
||||||
|
|
||||||
|
logger.info(f"Starting migration - APP-Version {__version__}")
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
DB_DIR = BASE_DIR / "data"
|
||||||
|
DB_PATH = DB_DIR / "app.db"
|
||||||
|
MIGRATIONS_DIR = BASE_DIR / "migrations"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_connection() -> sqlite3.Connection:
|
||||||
|
DB_DIR.mkdir(exist_ok=True)
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def get_applied_versions(conn: sqlite3.Connection) -> set[str]:
|
||||||
|
"""
|
||||||
|
Ließt aus schema_version, welche Migrationen schon gelaufen sind.
|
||||||
|
Falls die Tabelle noch nicht existiert → leeres Set.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cur = conn.execute("SELECT version FROM schema_version")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return {r[0] for r in rows}
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
# Tabelle existiert noch nicht (z.B. bei initialer DB)
|
||||||
|
|
||||||
|
return set()
|
||||||
|
|
||||||
|
def apply_migrations():
|
||||||
|
if not MIGRATIONS_DIR.exists():
|
||||||
|
raise SystemExit(f"Migrations-Ordner nicht gefunden: {MIGRATIONS_DIR}")
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
|
||||||
|
try:
|
||||||
|
applied = get_applied_versions(conn)
|
||||||
|
|
||||||
|
# Alle .sql-Dateien alphabetisch sortiert
|
||||||
|
sql_files = sorted(MIGRATIONS_DIR.glob("*.sql"))
|
||||||
|
|
||||||
|
if not sql_files:
|
||||||
|
logger.info("Keine Migrationsdateien gefunden.")
|
||||||
|
return
|
||||||
|
|
||||||
|
for path in sql_files:
|
||||||
|
version = path.stem # z.B. '20250220_120000_initial'
|
||||||
|
|
||||||
|
if version in applied:
|
||||||
|
logger.info(f"[SKIP] {version} (bereits angewendet)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"[APPLY] {version}")
|
||||||
|
sql = path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Eine Transaktion pro Datei
|
||||||
|
with conn:
|
||||||
|
conn.executescript(sql)
|
||||||
|
except Exception as e:
|
||||||
|
logger.info(f"Fehler in Migration {version}: {e}")
|
||||||
|
# hier nicht weiter machen
|
||||||
|
raise
|
||||||
|
logger.info(f"Migrationen abgeschlossen. DB: {DB_PATH}")
|
||||||
|
#print(f"Migrationen abgeschlossen. DB: {DB_PATH}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
apply_migrations()
|
||||||
0
app/tools/__init__.py
Normal file
0
app/tools/__init__.py
Normal file
1
app/version.py
Normal file
1
app/version.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__version__ = "0.0.1"
|
||||||
0
config/settings.env
Normal file
0
config/settings.env
Normal file
BIN
data/app.db
Normal file
BIN
data/app.db
Normal file
Binary file not shown.
20
migrations/20251128_210200_initial.sql
Normal file
20
migrations/20251128_210200_initial.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
|
version TEXT PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date_create TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash BLOB NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'guest'
|
||||||
|
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO schema_version (version) VALUES ('20251128_210200_initial');
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Reference in New Issue
Block a user