Compare commits

...

3 Commits

Author SHA1 Message Date
hansi
6158f2ddff Add authentication 2025-11-29 12:04:58 +01:00
hansi
fe33d714bd add logging and database migration 2025-11-29 00:15:41 +01:00
hansi
6f9c5eb34e Initialize project structure 2025-11-28 08:53:29 +01:00
11 changed files with 391 additions and 1 deletions

150
README.md
View File

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

1
app/version.py Normal file
View File

@@ -0,0 +1 @@
__version__ = "0.0.1"

0
config/settings.env Normal file
View File

BIN
data/app.db Normal file

Binary file not shown.

View 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;