update
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
AzA Intern Drag-and-Drop Backup - 20260521_125437
|
||||
=====================================
|
||||
|
||||
Wiederherstellung:
|
||||
1. intern_portal Ordner umbenennen oder loeschen
|
||||
2. Kopieren: backup_aza_intern_dragdrop_20260521_125437\intern_portal -> intern_portal
|
||||
|
||||
Rollback lokal:
|
||||
Remove-Item -Recurse -Force "c:\Users\surov\Documents\AZA_GIT\aza\AzA march 2026\intern_portal"
|
||||
Copy-Item -Recurse "c:\Users\surov\Documents\AZA_GIT\aza\AzA march 2026\backup_aza_intern_dragdrop_20260521_125437\intern_portal" "c:\Users\surov\Documents\AZA_GIT\aza\AzA march 2026\intern_portal"
|
||||
@@ -0,0 +1,4 @@
|
||||
# AzA Intern – .env Vorlage (kopieren nach .env, Werte setzen)
|
||||
AZA_INTERN_SESSION_SECRET=
|
||||
AZA_INTERN_ADMIN_USER=
|
||||
AZA_INTERN_ADMIN_PASSWORD=
|
||||
6
AzA march 2026/backup_aza_intern_dragdrop_20260521_125437/intern_portal/.gitignore
vendored
Normal file
6
AzA march 2026/backup_aza_intern_dragdrop_20260521_125437/intern_portal/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
uploads/
|
||||
backups/
|
||||
intern.db
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,13 @@
|
||||
# AzA Intern Portal – Caddy-Snippet (Referenz)
|
||||
# Erst anwenden, wenn DNS für intern.aza-medwork.ch auf den Server zeigt.
|
||||
# Vorher: bestehende Caddyfile sichern!
|
||||
#
|
||||
# In /etc/caddy/Caddyfile ergänzen (NICHT die bestehenden Routen überschreiben):
|
||||
|
||||
intern.aza-medwork.ch {
|
||||
reverse_proxy 127.0.0.1:8088
|
||||
}
|
||||
|
||||
# Danach:
|
||||
# caddy validate --config /etc/caddy/Caddyfile
|
||||
# systemctl reload caddy
|
||||
@@ -0,0 +1,21 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p uploads backups
|
||||
|
||||
ENV AZA_INTERN_DB_PATH=/data/intern.db
|
||||
ENV AZA_INTERN_UPLOAD_DIR=/data/uploads
|
||||
ENV AZA_INTERN_BACKUP_DIR=/data/backups
|
||||
|
||||
EXPOSE 8088
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8088"]
|
||||
@@ -0,0 +1,63 @@
|
||||
AzA Intern Portal – Deployment auf Hetzner
|
||||
==========================================
|
||||
|
||||
Ziel-URL: https://intern.aza-medwork.ch
|
||||
Server-Pfad: /root/aza-intern-portal
|
||||
|
||||
Voraussetzungen
|
||||
---------------
|
||||
- Docker und docker-compose auf dem Server
|
||||
- Caddy als Reverse-Proxy (bestehend)
|
||||
- DNS A-Record: intern.aza-medwork.ch -> Server-IP (178.104.51.177)
|
||||
|
||||
Schritte
|
||||
--------
|
||||
1. Backup erstellen (vor Deploy):
|
||||
mkdir -p /root/backups/aza_intern_portal_predeploy_YYYYMMDD_HHMMSS
|
||||
cp -a /root/aza-intern-portal /root/backups/.../ (falls vorhanden)
|
||||
|
||||
2. Verzeichnis anlegen und Dateien hochladen:
|
||||
mkdir -p /root/aza-intern-portal
|
||||
(rsync/scp des intern_portal-Inhalts)
|
||||
|
||||
3. .env anlegen (Werte NICHT ins Repo committen):
|
||||
cp .env.example .env
|
||||
AZA_INTERN_SESSION_SECRET=<zufälliger 64-Zeichen-Hex-String>
|
||||
AZA_INTERN_ADMIN_USER=<admin-benutzername>
|
||||
AZA_INTERN_ADMIN_PASSWORD=<sicheres-passwort>
|
||||
|
||||
4. Docker starten:
|
||||
cd /root/aza-intern-portal
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
|
||||
5. Healthcheck:
|
||||
curl -s http://127.0.0.1:8088/health
|
||||
|
||||
6. Caddy (nur wenn DNS bereit):
|
||||
- Caddyfile sichern: cp /etc/caddy/Caddyfile /root/backups/.../Caddyfile.bak
|
||||
- Snippet aus Caddyfile.snippet ergänzen
|
||||
- caddy validate --config /etc/caddy/Caddyfile
|
||||
- systemctl reload caddy
|
||||
|
||||
7. Im Browser testen: https://intern.aza-medwork.ch
|
||||
|
||||
Wichtig
|
||||
-------
|
||||
- /root/aza-app (produktive AzA-App) NICHT verändern
|
||||
- Portal läuft nur auf 127.0.0.1:8088, extern nur via Caddy
|
||||
- Keine Patientendaten, API-Keys oder Passwörter hochladen
|
||||
|
||||
Backup (manuell)
|
||||
----------------
|
||||
Regelmässig sichern:
|
||||
/data/intern.db (Docker-Volume aza_intern_data)
|
||||
/data/uploads/
|
||||
|
||||
Beispiel:
|
||||
docker compose exec aza-intern ls /data
|
||||
docker cp aza-intern-portal:/data/intern.db ./backups/intern_YYYYMMDD.db
|
||||
|
||||
2FA
|
||||
---
|
||||
Für eine spätere Version vorgesehen. Session-Cookies sind httpOnly (Starlette SessionMiddleware).
|
||||
@@ -0,0 +1,50 @@
|
||||
AzA Intern Portal – Restore / Rollback
|
||||
======================================
|
||||
|
||||
Lokal (Windows)
|
||||
---------------
|
||||
Backup-Ordner: backup_aza_intern_portal_YYYYMMDD_HHMMSS\
|
||||
- intern_portal\ (falls vor Deploy vorhanden)
|
||||
- README_RESTORE.txt
|
||||
|
||||
Rollback lokal:
|
||||
1. intern_portal\ löschen oder umbenennen
|
||||
2. Aus Backup wiederherstellen:
|
||||
xcopy /E /I backup_...\intern_portal intern_portal
|
||||
|
||||
Hetzner – Rollback nach fehlgeschlagenem Deploy
|
||||
------------------------------------------------
|
||||
1. Container stoppen:
|
||||
cd /root/aza-intern-portal
|
||||
docker compose down
|
||||
|
||||
2. Aus Pre-Deploy-Backup wiederherstellen:
|
||||
rm -rf /root/aza-intern-portal
|
||||
cp -a /root/backups/aza_intern_portal_predeploy_YYYYMMDD_HHMMSS/aza-intern-portal /root/
|
||||
|
||||
3. Caddy-Rollback (falls Caddyfile geändert):
|
||||
cp /root/backups/.../Caddyfile.bak /etc/caddy/Caddyfile
|
||||
caddy validate --config /etc/caddy/Caddyfile
|
||||
systemctl reload caddy
|
||||
|
||||
4. Neu starten:
|
||||
cd /root/aza-intern-portal
|
||||
docker compose up -d
|
||||
|
||||
Datenbank-Restore
|
||||
-----------------
|
||||
docker compose down
|
||||
docker cp ./backups/intern_YYYYMMDD.db aza-intern-portal:/data/intern.db
|
||||
docker compose up -d
|
||||
|
||||
Uploads-Restore
|
||||
---------------
|
||||
docker cp ./backups/uploads/. aza-intern-portal:/data/uploads/
|
||||
|
||||
Komplett entfernen (nur Intern-Portal)
|
||||
--------------------------------------
|
||||
cd /root/aza-intern-portal && docker compose down -v
|
||||
rm -rf /root/aza-intern-portal
|
||||
(Caddy-Snippet für intern.aza-medwork.ch manuell entfernen, falls gesetzt)
|
||||
|
||||
Produktive /root/aza-app wird dabei NICHT berührt.
|
||||
@@ -0,0 +1,940 @@
|
||||
"""
|
||||
AzA Intern – separates internes Webportal (Phase 1).
|
||||
Nicht Teil des produktiven AzA-Hauptsystems.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Generator, Optional
|
||||
|
||||
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from passlib.context import CryptContext
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
DB_PATH = Path(os.environ.get("AZA_INTERN_DB_PATH", str(BASE_DIR / "intern.db")))
|
||||
UPLOAD_DIR = Path(os.environ.get("AZA_INTERN_UPLOAD_DIR", str(BASE_DIR / "uploads")))
|
||||
BACKUP_DIR = Path(os.environ.get("AZA_INTERN_BACKUP_DIR", str(BASE_DIR / "backups")))
|
||||
TEMPLATES_DIR = BASE_DIR / "templates"
|
||||
STATIC_DIR = BASE_DIR / "static"
|
||||
|
||||
MAX_UPLOAD_BYTES = 50 * 1024 * 1024
|
||||
ALLOWED_EXTENSIONS = {
|
||||
".pdf", ".png", ".jpg", ".jpeg", ".webp",
|
||||
".docx", ".xlsx", ".pptx", ".txt", ".md", ".zip",
|
||||
}
|
||||
|
||||
TASK_STATUSES = ["Neu", "In Arbeit", "Warten auf André", "Erledigt", "Archiv"]
|
||||
TASK_PRIORITIES = ["niedrig", "normal", "hoch"]
|
||||
CATEGORIES = [
|
||||
"Recherche", "Webseite", "Flyer / Marketing", "Screenshots / Bugs",
|
||||
"Anleitung", "Recht / Datenschutz", "Installer / Release", "Sonstiges",
|
||||
]
|
||||
ROLES = ["admin", "assistant"]
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
app = FastAPI(title="AzA Intern", docs_url=None, redoc_url=None)
|
||||
session_secret = os.environ.get("AZA_INTERN_SESSION_SECRET") or secrets.token_hex(32)
|
||||
app.add_middleware(SessionMiddleware, secret_key=session_secret, https_only=False, same_site="lax")
|
||||
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db() -> Generator[sqlite3.Connection, None, None]:
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with get_db() as conn:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'assistant',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'Neu',
|
||||
priority TEXT NOT NULL DEFAULT 'normal',
|
||||
category TEXT,
|
||||
assigned_to INTEGER,
|
||||
due_date TEXT,
|
||||
created_by INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (assigned_to) REFERENCES users(id),
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS task_comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
comment TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
original_filename TEXT NOT NULL,
|
||||
stored_filename TEXT NOT NULL UNIQUE,
|
||||
content_type TEXT,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
category TEXT,
|
||||
description TEXT,
|
||||
uploaded_by INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (uploaded_by) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT,
|
||||
category TEXT,
|
||||
created_by INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS item_tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tag_id INTEGER NOT NULL,
|
||||
item_type TEXT NOT NULL,
|
||||
item_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,
|
||||
UNIQUE(tag_id, item_type, item_id)
|
||||
);
|
||||
""")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def user_count() -> int:
|
||||
with get_db() as conn:
|
||||
row = conn.execute("SELECT COUNT(*) AS c FROM users").fetchone()
|
||||
return int(row["c"])
|
||||
|
||||
|
||||
def create_admin_from_env() -> bool:
|
||||
username = os.environ.get("AZA_INTERN_ADMIN_USER", "").strip()
|
||||
password = os.environ.get("AZA_INTERN_ADMIN_PASSWORD", "").strip()
|
||||
if not username or not password:
|
||||
return False
|
||||
if user_count() > 0:
|
||||
return False
|
||||
ts = now_iso()
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash, role, is_active, created_at, updated_at) VALUES (?, ?, 'admin', 1, ?, ?)",
|
||||
(username, hash_password(password), ts, ts),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def get_csrf_token(request: Request) -> str:
|
||||
if "csrf_token" not in request.session:
|
||||
request.session["csrf_token"] = secrets.token_hex(32)
|
||||
return request.session["csrf_token"]
|
||||
|
||||
|
||||
def verify_csrf(request: Request, token: str) -> None:
|
||||
expected = request.session.get("csrf_token")
|
||||
if not expected or not token or not secrets.compare_digest(expected, token):
|
||||
raise HTTPException(status_code=403, detail="CSRF-Token ungültig")
|
||||
|
||||
|
||||
def get_current_user(request: Request) -> Optional[dict[str, Any]]:
|
||||
uid = request.session.get("user_id")
|
||||
if not uid:
|
||||
return None
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, username, role, is_active FROM users WHERE id = ?",
|
||||
(uid,),
|
||||
).fetchone()
|
||||
if not row or not row["is_active"]:
|
||||
return None
|
||||
return dict(row)
|
||||
|
||||
|
||||
def require_login(request: Request) -> dict[str, Any]:
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=303, headers={"Location": "/login"})
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(request: Request) -> dict[str, Any]:
|
||||
user = require_login(request)
|
||||
if user["role"] != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich")
|
||||
return user
|
||||
|
||||
|
||||
def template_ctx(request: Request, user: Optional[dict] = None, **extra) -> dict:
|
||||
ctx = {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
"categories": CATEGORIES,
|
||||
"task_statuses": TASK_STATUSES,
|
||||
"task_priorities": TASK_PRIORITIES,
|
||||
"roles": ROLES,
|
||||
}
|
||||
ctx.update(extra)
|
||||
return ctx
|
||||
|
||||
|
||||
def safe_filename(name: str) -> str:
|
||||
name = os.path.basename(name or "file")
|
||||
name = re.sub(r"[^\w.\- ]", "_", name, flags=re.UNICODE)
|
||||
return name[:200] or "file"
|
||||
|
||||
|
||||
def allowed_extension(filename: str) -> bool:
|
||||
ext = Path(filename).suffix.lower()
|
||||
return ext in ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
def get_or_create_tag(conn: sqlite3.Connection, name: str) -> int:
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise ValueError("Leerer Tag")
|
||||
row = conn.execute("SELECT id FROM tags WHERE name = ?", (name,)).fetchone()
|
||||
if row:
|
||||
return int(row["id"])
|
||||
cur = conn.execute("INSERT INTO tags (name) VALUES (?)", (name,))
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def set_item_tags(conn: sqlite3.Connection, item_type: str, item_id: int, tag_names: str) -> None:
|
||||
conn.execute(
|
||||
"DELETE FROM item_tags WHERE item_type = ? AND item_id = ?",
|
||||
(item_type, item_id),
|
||||
)
|
||||
for part in re.split(r"[,;]", tag_names or ""):
|
||||
tag = part.strip()
|
||||
if not tag:
|
||||
continue
|
||||
tag_id = get_or_create_tag(conn, tag)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO item_tags (tag_id, item_type, item_id) VALUES (?, ?, ?)",
|
||||
(tag_id, item_type, item_id),
|
||||
)
|
||||
|
||||
|
||||
def get_item_tags(conn: sqlite3.Connection, item_type: str, item_id: int) -> list[str]:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT t.name FROM tags t
|
||||
JOIN item_tags it ON it.tag_id = t.id
|
||||
WHERE it.item_type = ? AND it.item_id = ?
|
||||
ORDER BY t.name
|
||||
""",
|
||||
(item_type, item_id),
|
||||
).fetchall()
|
||||
return [r["name"] for r in rows]
|
||||
|
||||
|
||||
def all_users(active_only: bool = True) -> list[dict]:
|
||||
with get_db() as conn:
|
||||
q = "SELECT id, username, role, is_active, created_at FROM users"
|
||||
if active_only:
|
||||
q += " WHERE is_active = 1"
|
||||
q += " ORDER BY username"
|
||||
return [dict(r) for r in conn.execute(q).fetchall()]
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup() -> None:
|
||||
init_db()
|
||||
create_admin_from_env()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok", "service": "aza-intern"}
|
||||
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
def login_page(request: Request):
|
||||
if get_current_user(request):
|
||||
return RedirectResponse("/", status_code=303)
|
||||
if user_count() == 0:
|
||||
return RedirectResponse("/setup", status_code=303)
|
||||
return templates.TemplateResponse(
|
||||
"login.html",
|
||||
template_ctx(request, error=request.query_params.get("error")),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
def login_submit(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
verify_csrf(request, csrf_token)
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, password_hash, is_active FROM users WHERE username = ?",
|
||||
(username.strip(),),
|
||||
).fetchone()
|
||||
if not row or not row["is_active"] or not verify_password(password, row["password_hash"]):
|
||||
return RedirectResponse("/login?error=1", status_code=303)
|
||||
request.session["user_id"] = row["id"]
|
||||
return RedirectResponse("/", status_code=303)
|
||||
|
||||
|
||||
@app.get("/setup", response_class=HTMLResponse)
|
||||
def setup_page(request: Request):
|
||||
if user_count() > 0:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
return templates.TemplateResponse("setup.html", template_ctx(request))
|
||||
|
||||
|
||||
@app.post("/setup")
|
||||
def setup_submit(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
password_confirm: str = Form(...),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
verify_csrf(request, csrf_token)
|
||||
if user_count() > 0:
|
||||
raise HTTPException(status_code=403, detail="Setup bereits abgeschlossen")
|
||||
username = username.strip()
|
||||
if len(username) < 3:
|
||||
return templates.TemplateResponse(
|
||||
"setup.html",
|
||||
template_ctx(request, error="Benutzername mindestens 3 Zeichen."),
|
||||
)
|
||||
if len(password) < 8:
|
||||
return templates.TemplateResponse(
|
||||
"setup.html",
|
||||
template_ctx(request, error="Passwort mindestens 8 Zeichen."),
|
||||
)
|
||||
if password != password_confirm:
|
||||
return templates.TemplateResponse(
|
||||
"setup.html",
|
||||
template_ctx(request, error="Passwörter stimmen nicht überein."),
|
||||
)
|
||||
ts = now_iso()
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash, role, is_active, created_at, updated_at) VALUES (?, ?, 'admin', 1, ?, ?)",
|
||||
(username, hash_password(password), ts, ts),
|
||||
)
|
||||
request.session["user_id"] = 1
|
||||
return RedirectResponse("/", status_code=303)
|
||||
|
||||
|
||||
@app.post("/logout")
|
||||
def logout(request: Request, csrf_token: str = Form(...)):
|
||||
verify_csrf(request, csrf_token)
|
||||
request.session.clear()
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def dashboard(request: Request):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
with get_db() as conn:
|
||||
open_tasks = conn.execute(
|
||||
"""
|
||||
SELECT t.*, u.username AS assigned_username
|
||||
FROM tasks t
|
||||
LEFT JOIN users u ON u.id = t.assigned_to
|
||||
WHERE t.status NOT IN ('Erledigt', 'Archiv')
|
||||
ORDER BY CASE t.priority WHEN 'hoch' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, t.updated_at DESC
|
||||
LIMIT 10
|
||||
"""
|
||||
).fetchall()
|
||||
waiting_tasks = conn.execute(
|
||||
"""
|
||||
SELECT t.*, u.username AS assigned_username
|
||||
FROM tasks t
|
||||
LEFT JOIN users u ON u.id = t.assigned_to
|
||||
WHERE t.status = 'Warten auf André'
|
||||
ORDER BY t.updated_at DESC LIMIT 5
|
||||
"""
|
||||
).fetchall()
|
||||
recent_files = conn.execute(
|
||||
"""
|
||||
SELECT f.*, u.username AS uploader
|
||||
FROM files f JOIN users u ON u.id = f.uploaded_by
|
||||
ORDER BY f.created_at DESC LIMIT 5
|
||||
"""
|
||||
).fetchall()
|
||||
recent_notes = conn.execute(
|
||||
"""
|
||||
SELECT n.*, u.username AS author
|
||||
FROM notes n JOIN users u ON u.id = n.created_by
|
||||
ORDER BY n.updated_at DESC LIMIT 5
|
||||
"""
|
||||
).fetchall()
|
||||
return templates.TemplateResponse(
|
||||
"dashboard.html",
|
||||
template_ctx(
|
||||
request, user,
|
||||
open_tasks=[dict(r) for r in open_tasks],
|
||||
waiting_tasks=[dict(r) for r in waiting_tasks],
|
||||
recent_files=[dict(r) for r in recent_files],
|
||||
recent_notes=[dict(r) for r in recent_notes],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/tasks", response_class=HTMLResponse)
|
||||
def tasks_list(request: Request, status: str = "", category: str = ""):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
q = """
|
||||
SELECT t.*, u.username AS assigned_username, c.username AS creator_username
|
||||
FROM tasks t
|
||||
LEFT JOIN users u ON u.id = t.assigned_to
|
||||
LEFT JOIN users c ON c.id = t.created_by
|
||||
WHERE 1=1
|
||||
"""
|
||||
params: list[Any] = []
|
||||
if status:
|
||||
q += " AND t.status = ?"
|
||||
params.append(status)
|
||||
if category:
|
||||
q += " AND t.category = ?"
|
||||
params.append(category)
|
||||
q += " ORDER BY t.updated_at DESC"
|
||||
with get_db() as conn:
|
||||
tasks = [dict(r) for r in conn.execute(q, params).fetchall()]
|
||||
for t in tasks:
|
||||
t["tags"] = get_item_tags(conn, "task", t["id"])
|
||||
return templates.TemplateResponse(
|
||||
"tasks_list.html",
|
||||
template_ctx(request, user, tasks=tasks, filter_status=status, filter_category=category),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/tasks/new", response_class=HTMLResponse)
|
||||
def task_new_page(request: Request):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
return templates.TemplateResponse(
|
||||
"task_form.html",
|
||||
template_ctx(request, user, task=None, users=all_users()),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/tasks/new")
|
||||
def task_create(
|
||||
request: Request,
|
||||
title: str = Form(...),
|
||||
description: str = Form(""),
|
||||
status: str = Form("Neu"),
|
||||
priority: str = Form("normal"),
|
||||
category: str = Form(""),
|
||||
assigned_to: str = Form(""),
|
||||
due_date: str = Form(""),
|
||||
tags: str = Form(""),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
user = require_login(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
if status not in TASK_STATUSES:
|
||||
status = "Neu"
|
||||
if priority not in TASK_PRIORITIES:
|
||||
priority = "normal"
|
||||
assign_id = int(assigned_to) if assigned_to.isdigit() else None
|
||||
ts = now_iso()
|
||||
with get_db() as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO tasks (title, description, status, priority, category, assigned_to, due_date, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(title.strip(), description.strip(), status, priority, category or None,
|
||||
assign_id, due_date or None, user["id"], ts, ts),
|
||||
)
|
||||
task_id = int(cur.lastrowid)
|
||||
set_item_tags(conn, "task", task_id, tags)
|
||||
return RedirectResponse(f"/tasks/{task_id}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/tasks/{task_id}", response_class=HTMLResponse)
|
||||
def task_detail(request: Request, task_id: int):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
with get_db() as conn:
|
||||
task = conn.execute(
|
||||
"""
|
||||
SELECT t.*, u.username AS assigned_username, c.username AS creator_username
|
||||
FROM tasks t
|
||||
LEFT JOIN users u ON u.id = t.assigned_to
|
||||
LEFT JOIN users c ON c.id = t.created_by
|
||||
WHERE t.id = ?
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404)
|
||||
comments = conn.execute(
|
||||
"""
|
||||
SELECT tc.*, u.username FROM task_comments tc
|
||||
JOIN users u ON u.id = tc.user_id
|
||||
WHERE tc.task_id = ? ORDER BY tc.created_at ASC
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
tags = get_item_tags(conn, "task", task_id)
|
||||
return templates.TemplateResponse(
|
||||
"task_detail.html",
|
||||
template_ctx(
|
||||
request, user,
|
||||
task=dict(task),
|
||||
comments=[dict(c) for c in comments],
|
||||
tags=tags,
|
||||
users=all_users(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/tasks/{task_id}/edit", response_class=HTMLResponse)
|
||||
def task_edit_page(request: Request, task_id: int):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
with get_db() as conn:
|
||||
task = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404)
|
||||
tags = ", ".join(get_item_tags(conn, "task", task_id))
|
||||
t = dict(task)
|
||||
t["tags_str"] = tags
|
||||
return templates.TemplateResponse(
|
||||
"task_form.html",
|
||||
template_ctx(request, user, task=t, users=all_users()),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/tasks/{task_id}/edit")
|
||||
def task_update(
|
||||
request: Request,
|
||||
task_id: int,
|
||||
title: str = Form(...),
|
||||
description: str = Form(""),
|
||||
status: str = Form("Neu"),
|
||||
priority: str = Form("normal"),
|
||||
category: str = Form(""),
|
||||
assigned_to: str = Form(""),
|
||||
due_date: str = Form(""),
|
||||
tags: str = Form(""),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
user = require_login(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
if status not in TASK_STATUSES:
|
||||
status = "Neu"
|
||||
if priority not in TASK_PRIORITIES:
|
||||
priority = "normal"
|
||||
assign_id = int(assigned_to) if assigned_to.isdigit() else None
|
||||
ts = now_iso()
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE tasks SET title=?, description=?, status=?, priority=?, category=?,
|
||||
assigned_to=?, due_date=?, updated_at=? WHERE id=?
|
||||
""",
|
||||
(title.strip(), description.strip(), status, priority, category or None,
|
||||
assign_id, due_date or None, ts, task_id),
|
||||
)
|
||||
set_item_tags(conn, "task", task_id, tags)
|
||||
return RedirectResponse(f"/tasks/{task_id}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/tasks/{task_id}/comment")
|
||||
def task_add_comment(
|
||||
request: Request,
|
||||
task_id: int,
|
||||
comment: str = Form(...),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
user = require_login(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
text = comment.strip()
|
||||
if not text:
|
||||
return RedirectResponse(f"/tasks/{task_id}", status_code=303)
|
||||
ts = now_iso()
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO task_comments (task_id, user_id, comment, created_at) VALUES (?, ?, ?, ?)",
|
||||
(task_id, user["id"], text, ts),
|
||||
)
|
||||
conn.execute("UPDATE tasks SET updated_at = ? WHERE id = ?", (ts, task_id))
|
||||
return RedirectResponse(f"/tasks/{task_id}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/files", response_class=HTMLResponse)
|
||||
def files_list(request: Request, category: str = ""):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
q = """
|
||||
SELECT f.*, u.username AS uploader
|
||||
FROM files f JOIN users u ON u.id = f.uploaded_by
|
||||
WHERE 1=1
|
||||
"""
|
||||
params: list[Any] = []
|
||||
if category:
|
||||
q += " AND f.category = ?"
|
||||
params.append(category)
|
||||
q += " ORDER BY f.created_at DESC"
|
||||
with get_db() as conn:
|
||||
files = [dict(r) for r in conn.execute(q, params).fetchall()]
|
||||
for f in files:
|
||||
f["tags"] = get_item_tags(conn, "file", f["id"])
|
||||
return templates.TemplateResponse(
|
||||
"files_list.html",
|
||||
template_ctx(request, user, files=files, filter_category=category),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/files/upload")
|
||||
async def file_upload(
|
||||
request: Request,
|
||||
upload: UploadFile = File(...),
|
||||
category: str = Form(""),
|
||||
description: str = Form(""),
|
||||
tags: str = Form(""),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
user = require_login(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
orig = safe_filename(upload.filename or "file")
|
||||
if not allowed_extension(orig):
|
||||
return RedirectResponse("/files?error=type", status_code=303)
|
||||
content = await upload.read()
|
||||
if len(content) > MAX_UPLOAD_BYTES:
|
||||
return RedirectResponse("/files?error=size", status_code=303)
|
||||
stored = f"{uuid.uuid4().hex}{Path(orig).suffix.lower()}"
|
||||
dest = UPLOAD_DIR / stored
|
||||
dest.write_bytes(content)
|
||||
ts = now_iso()
|
||||
with get_db() as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO files (original_filename, stored_filename, content_type, size_bytes, category, description, uploaded_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(orig, stored, upload.content_type or "application/octet-stream",
|
||||
len(content), category or None, description.strip() or None, user["id"], ts),
|
||||
)
|
||||
set_item_tags(conn, "file", int(cur.lastrowid), tags)
|
||||
return RedirectResponse("/files", status_code=303)
|
||||
|
||||
|
||||
@app.get("/files/{file_id}/download")
|
||||
def file_download(request: Request, file_id: int):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
with get_db() as conn:
|
||||
row = conn.execute("SELECT * FROM files WHERE id = ?", (file_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404)
|
||||
path = UPLOAD_DIR / row["stored_filename"]
|
||||
if not path.is_file() or ".." in row["stored_filename"]:
|
||||
raise HTTPException(status_code=404)
|
||||
return FileResponse(
|
||||
path,
|
||||
filename=row["original_filename"],
|
||||
media_type=row["content_type"] or "application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/notes", response_class=HTMLResponse)
|
||||
def notes_list(request: Request, category: str = ""):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
q = """
|
||||
SELECT n.*, u.username AS author
|
||||
FROM notes n JOIN users u ON u.id = n.created_by
|
||||
WHERE 1=1
|
||||
"""
|
||||
params: list[Any] = []
|
||||
if category:
|
||||
q += " AND n.category = ?"
|
||||
params.append(category)
|
||||
q += " ORDER BY n.updated_at DESC"
|
||||
with get_db() as conn:
|
||||
notes = [dict(r) for r in conn.execute(q, params).fetchall()]
|
||||
for n in notes:
|
||||
n["tags"] = get_item_tags(conn, "note", n["id"])
|
||||
return templates.TemplateResponse(
|
||||
"notes_list.html",
|
||||
template_ctx(request, user, notes=notes, filter_category=category),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/notes/new", response_class=HTMLResponse)
|
||||
def note_new_page(request: Request):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
return templates.TemplateResponse(
|
||||
"note_form.html",
|
||||
template_ctx(request, user, note=None),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/notes/new")
|
||||
def note_create(
|
||||
request: Request,
|
||||
title: str = Form(...),
|
||||
body: str = Form(""),
|
||||
category: str = Form(""),
|
||||
tags: str = Form(""),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
user = require_login(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
ts = now_iso()
|
||||
with get_db() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO notes (title, body, category, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(title.strip(), body.strip(), category or None, user["id"], ts, ts),
|
||||
)
|
||||
set_item_tags(conn, "note", int(cur.lastrowid), tags)
|
||||
return RedirectResponse("/notes", status_code=303)
|
||||
|
||||
|
||||
@app.get("/notes/{note_id}/edit", response_class=HTMLResponse)
|
||||
def note_edit_page(request: Request, note_id: int):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
with get_db() as conn:
|
||||
note = conn.execute("SELECT * FROM notes WHERE id = ?", (note_id,)).fetchone()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404)
|
||||
tags = ", ".join(get_item_tags(conn, "note", note_id))
|
||||
n = dict(note)
|
||||
n["tags_str"] = tags
|
||||
return templates.TemplateResponse(
|
||||
"note_form.html",
|
||||
template_ctx(request, user, note=n),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/notes/{note_id}/edit")
|
||||
def note_update(
|
||||
request: Request,
|
||||
note_id: int,
|
||||
title: str = Form(...),
|
||||
body: str = Form(""),
|
||||
category: str = Form(""),
|
||||
tags: str = Form(""),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
user = require_login(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
ts = now_iso()
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE notes SET title=?, body=?, category=?, updated_at=? WHERE id=?",
|
||||
(title.strip(), body.strip(), category or None, ts, note_id),
|
||||
)
|
||||
set_item_tags(conn, "note", note_id, tags)
|
||||
return RedirectResponse("/notes", status_code=303)
|
||||
|
||||
|
||||
@app.get("/search", response_class=HTMLResponse)
|
||||
def search_page(request: Request, q: str = ""):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
results: dict[str, list] = {"tasks": [], "files": [], "notes": []}
|
||||
term = q.strip()
|
||||
if term:
|
||||
like = f"%{term}%"
|
||||
with get_db() as conn:
|
||||
results["tasks"] = [
|
||||
dict(r) for r in conn.execute(
|
||||
"""
|
||||
SELECT id, title, status, category, updated_at FROM tasks
|
||||
WHERE title LIKE ? OR description LIKE ? OR category LIKE ?
|
||||
ORDER BY updated_at DESC LIMIT 30
|
||||
""",
|
||||
(like, like, like),
|
||||
).fetchall()
|
||||
]
|
||||
results["files"] = [
|
||||
dict(r) for r in conn.execute(
|
||||
"""
|
||||
SELECT id, original_filename, category, description, created_at FROM files
|
||||
WHERE original_filename LIKE ? OR description LIKE ? OR category LIKE ?
|
||||
ORDER BY created_at DESC LIMIT 30
|
||||
""",
|
||||
(like, like, like),
|
||||
).fetchall()
|
||||
]
|
||||
results["notes"] = [
|
||||
dict(r) for r in conn.execute(
|
||||
"""
|
||||
SELECT id, title, category, updated_at FROM notes
|
||||
WHERE title LIKE ? OR body LIKE ? OR category LIKE ?
|
||||
ORDER BY updated_at DESC LIMIT 30
|
||||
""",
|
||||
(like, like, like),
|
||||
).fetchall()
|
||||
]
|
||||
tag_rows = conn.execute(
|
||||
"SELECT item_type, item_id FROM item_tags it JOIN tags t ON t.id = it.tag_id WHERE t.name LIKE ?",
|
||||
(like,),
|
||||
).fetchall()
|
||||
for tr in tag_rows:
|
||||
itype, iid = tr["item_type"], tr["item_id"]
|
||||
if itype == "task" and not any(t["id"] == iid for t in results["tasks"]):
|
||||
row = conn.execute("SELECT id, title, status, category, updated_at FROM tasks WHERE id=?", (iid,)).fetchone()
|
||||
if row:
|
||||
results["tasks"].append(dict(row))
|
||||
elif itype == "file" and not any(f["id"] == iid for f in results["files"]):
|
||||
row = conn.execute(
|
||||
"SELECT id, original_filename, category, description, created_at FROM files WHERE id=?", (iid,),
|
||||
).fetchone()
|
||||
if row:
|
||||
results["files"].append(dict(row))
|
||||
elif itype == "note" and not any(n["id"] == iid for n in results["notes"]):
|
||||
row = conn.execute("SELECT id, title, category, updated_at FROM notes WHERE id=?", (iid,)).fetchone()
|
||||
if row:
|
||||
results["notes"].append(dict(row))
|
||||
return templates.TemplateResponse(
|
||||
"search.html",
|
||||
template_ctx(request, user, query=term, results=results),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/admin", response_class=HTMLResponse)
|
||||
def admin_page(request: Request):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
if user["role"] != "admin":
|
||||
raise HTTPException(status_code=403, detail="Nur für Administratoren")
|
||||
with get_db() as conn:
|
||||
users = [dict(r) for r in conn.execute(
|
||||
"SELECT id, username, role, is_active, created_at, updated_at FROM users ORDER BY username"
|
||||
).fetchall()]
|
||||
return templates.TemplateResponse(
|
||||
"admin.html",
|
||||
template_ctx(request, user, users=users, message=request.query_params.get("msg")),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/admin/users/new")
|
||||
def admin_create_user(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
role: str = Form("assistant"),
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
admin = require_admin(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
_ = admin
|
||||
username = username.strip()
|
||||
if len(username) < 3 or len(password) < 8:
|
||||
return RedirectResponse("/admin?msg=invalid", status_code=303)
|
||||
if role not in ROLES:
|
||||
role = "assistant"
|
||||
ts = now_iso()
|
||||
try:
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash, role, is_active, created_at, updated_at) VALUES (?, ?, ?, 1, ?, ?)",
|
||||
(username, hash_password(password), role, ts, ts),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
return RedirectResponse("/admin?msg=exists", status_code=303)
|
||||
return RedirectResponse("/admin?msg=created", status_code=303)
|
||||
|
||||
|
||||
@app.post("/admin/users/{user_id}/toggle")
|
||||
def admin_toggle_user(
|
||||
request: Request,
|
||||
user_id: int,
|
||||
csrf_token: str = Form(...),
|
||||
):
|
||||
admin = require_admin(request)
|
||||
verify_csrf(request, csrf_token)
|
||||
if user_id == admin["id"]:
|
||||
return RedirectResponse("/admin?msg=self", status_code=303)
|
||||
ts = now_iso()
|
||||
with get_db() as conn:
|
||||
row = conn.execute("SELECT is_active FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404)
|
||||
new_val = 0 if row["is_active"] else 1
|
||||
conn.execute(
|
||||
"UPDATE users SET is_active = ?, updated_at = ? WHERE id = ?",
|
||||
(new_val, ts, user_id),
|
||||
)
|
||||
return RedirectResponse("/admin?msg=toggled", status_code=303)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("app:app", host="127.0.0.1", port=8088, reload=True)
|
||||
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
aza-intern:
|
||||
build: .
|
||||
container_name: aza-intern-portal
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8088:8088"
|
||||
volumes:
|
||||
- aza_intern_data:/data
|
||||
environment:
|
||||
- AZA_INTERN_SESSION_SECRET=${AZA_INTERN_SESSION_SECRET}
|
||||
- AZA_INTERN_ADMIN_USER=${AZA_INTERN_ADMIN_USER:-}
|
||||
- AZA_INTERN_ADMIN_PASSWORD=${AZA_INTERN_ADMIN_PASSWORD:-}
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
volumes:
|
||||
aza_intern_data:
|
||||
@@ -0,0 +1,8 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
jinja2==3.1.5
|
||||
python-multipart==0.0.20
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.2.1
|
||||
itsdangerous==2.2.0
|
||||
aiofiles==24.1.0
|
||||
@@ -0,0 +1,376 @@
|
||||
:root {
|
||||
--aza-blue: #1a5f8a;
|
||||
--aza-blue-dark: #134a6b;
|
||||
--aza-blue-light: #e8f2f8;
|
||||
--aza-accent: #2d8bc9;
|
||||
--text: #1e293b;
|
||||
--text-muted: #64748b;
|
||||
--border: #cbd5e1;
|
||||
--bg: #f4f7fa;
|
||||
--white: #ffffff;
|
||||
--success: #059669;
|
||||
--warning: #d97706;
|
||||
--danger: #dc2626;
|
||||
--radius: 8px;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--aza-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: var(--aza-blue-dark);
|
||||
color: var(--white);
|
||||
padding: 1.5rem 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 0 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.sidebar-brand h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-brand small {
|
||||
opacity: 0.75;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.sidebar nav a {
|
||||
display: block;
|
||||
padding: 0.65rem 1.25rem;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.sidebar nav a:hover,
|
||||
.sidebar nav a.active {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidebar-user {
|
||||
padding: 1rem 1.25rem;
|
||||
margin-top: auto;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.85;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 1.5rem 2rem;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 1.5rem;
|
||||
color: var(--aza-blue-dark);
|
||||
}
|
||||
|
||||
.hint-banner {
|
||||
background: var(--aza-blue-light);
|
||||
border-left: 4px solid var(--aza-blue);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--white);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--aza-blue);
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--aza-blue);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--aza-blue-dark);
|
||||
text-decoration: none;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--white);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--aza-blue-light);
|
||||
color: var(--aza-blue-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-neu { background: #dbeafe; color: #1e40af; }
|
||||
.badge-arbeit { background: #fef3c7; color: #92400e; }
|
||||
.badge-warten { background: #fce7f3; color: #9d174d; }
|
||||
.badge-erledigt { background: #d1fae5; color: #065f46; }
|
||||
.badge-archiv { background: #e2e8f0; color: #475569; }
|
||||
|
||||
.badge-niedrig { background: #e2e8f0; color: #475569; }
|
||||
.badge-normal { background: #dbeafe; color: #1e40af; }
|
||||
.badge-hoch { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
.alert {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: var(--aza-blue-light);
|
||||
color: var(--aza-blue-dark);
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, var(--aza-blue-dark) 0%, var(--aza-blue) 100%);
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: var(--white);
|
||||
padding: 2rem;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-box h1 {
|
||||
text-align: center;
|
||||
color: var(--aza-blue-dark);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.login-box .subtitle {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.comment-list {
|
||||
list-style: none;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.comment-list li {
|
||||
padding: 0.75rem;
|
||||
background: var(--bg);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.comment-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background: var(--aza-blue-light);
|
||||
color: var(--aza-blue);
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.sidebar nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sidebar nav a {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{% extends "layout.html" %}
|
||||
{% set active = 'admin' %}
|
||||
{% block page_title %}Admin – AzA Intern{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Benutzerverwaltung</h2>
|
||||
</div>
|
||||
|
||||
{% if message == 'created' %}
|
||||
<div class="alert alert-success">Benutzer erstellt.</div>
|
||||
{% elif message == 'exists' %}
|
||||
<div class="alert alert-error">Benutzername existiert bereits.</div>
|
||||
{% elif message == 'invalid' %}
|
||||
<div class="alert alert-error">Ungültige Eingabe (Benutzername min. 3, Passwort min. 8 Zeichen).</div>
|
||||
{% elif message == 'self' %}
|
||||
<div class="alert alert-error">Eigenes Konto kann nicht deaktiviert werden.</div>
|
||||
{% elif message == 'toggled' %}
|
||||
<div class="alert alert-success">Benutzerstatus geändert.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<h3>Neuen Benutzer anlegen</h3>
|
||||
<form method="post" action="/admin/users/new">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="grid-3">
|
||||
<div class="form-group">
|
||||
<label for="username">Benutzername</label>
|
||||
<input type="text" id="username" name="username" required minlength="3">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" required minlength="8">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="role">Rolle</label>
|
||||
<select id="role" name="role">
|
||||
{% for r in roles %}
|
||||
<option value="{{ r }}">{{ r }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Benutzer erstellen</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Bestehende Benutzer</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Benutzername</th><th>Rolle</th><th>Status</th><th>Erstellt</th><th>Aktion</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>{{ u.username }}</td>
|
||||
<td>{{ u.role }}</td>
|
||||
<td>{% if u.is_active %}Aktiv{% else %}Deaktiviert{% endif %}</td>
|
||||
<td>{{ u.created_at }}</td>
|
||||
<td>
|
||||
{% if u.id != user.id %}
|
||||
<form method="post" action="/admin/users/{{ u.id }}/toggle" style="display:inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-sm btn-secondary">
|
||||
{% if u.is_active %}Deaktivieren{% else %}Aktivieren{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<em>(Sie)</em>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p style="font-size:0.85rem;color:#64748b;margin-top:1rem">
|
||||
2FA für Admin- und Assistenten-Konten ist für eine spätere Version vorgesehen.
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}AzA Intern{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,91 @@
|
||||
{% extends "layout.html" %}
|
||||
{% set active = 'dashboard' %}
|
||||
{% block page_title %}Dashboard – AzA Intern{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Dashboard</h2>
|
||||
</div>
|
||||
|
||||
<div class="hint-banner">
|
||||
Bitte keine Patientendaten, API-Keys oder Passwörter hochladen.
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<h3>Offene Aufgaben</h3>
|
||||
{% if open_tasks %}
|
||||
<table>
|
||||
<thead><tr><th>Titel</th><th>Status</th><th>Priorität</th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in open_tasks %}
|
||||
<tr>
|
||||
<td><a href="/tasks/{{ t.id }}">{{ t.title }}</a></td>
|
||||
<td>{{ t.status }}</td>
|
||||
<td>{{ t.priority }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty-state">Keine offenen Aufgaben.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Warten auf André</h3>
|
||||
{% if waiting_tasks %}
|
||||
<table>
|
||||
<thead><tr><th>Titel</th><th>Kategorie</th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in waiting_tasks %}
|
||||
<tr>
|
||||
<td><a href="/tasks/{{ t.id }}">{{ t.title }}</a></td>
|
||||
<td>{{ t.category or '–' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty-state">Keine Aufgaben in Wartestatus.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Letzte Uploads</h3>
|
||||
{% if recent_files %}
|
||||
<table>
|
||||
<thead><tr><th>Datei</th><th>Kategorie</th></tr></thead>
|
||||
<tbody>
|
||||
{% for f in recent_files %}
|
||||
<tr>
|
||||
<td><a href="/files/{{ f.id }}/download">{{ f.original_filename }}</a></td>
|
||||
<td>{{ f.category or '–' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty-state">Noch keine Dateien.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Letzte Notizen</h3>
|
||||
{% if recent_notes %}
|
||||
<table>
|
||||
<thead><tr><th>Titel</th><th>Kategorie</th></tr></thead>
|
||||
<tbody>
|
||||
{% for n in recent_notes %}
|
||||
<tr>
|
||||
<td>{{ n.title }}</td>
|
||||
<td>{{ n.category or '–' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty-state">Noch keine Notizen.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,87 @@
|
||||
{% extends "layout.html" %}
|
||||
{% set active = 'files' %}
|
||||
{% block page_title %}Dateien – AzA Intern{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Dateien</h2>
|
||||
</div>
|
||||
|
||||
{% if request.query_params.get('error') == 'type' %}
|
||||
<div class="alert alert-error">Dateityp nicht erlaubt.</div>
|
||||
{% elif request.query_params.get('error') == 'size' %}
|
||||
<div class="alert alert-error">Datei zu gross (max. 50 MB).</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<h3>Datei hochladen</h3>
|
||||
<form method="post" action="/files/upload" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label for="upload">Datei *</label>
|
||||
<input type="file" id="upload" name="upload" required>
|
||||
<small style="color:#64748b">Erlaubt: pdf, png, jpg, webp, docx, xlsx, pptx, txt, md, zip (max. 50 MB)</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="category">Kategorie</label>
|
||||
<select id="category" name="category">
|
||||
<option value="">–</option>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c }}">{{ c }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Beschreibung</label>
|
||||
<input type="text" id="description" name="description">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tags">Tags (kommagetrennt)</label>
|
||||
<input type="text" id="tags" name="tags">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Hochladen</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form method="get" class="search-bar">
|
||||
<select name="category">
|
||||
<option value="">Alle Kategorien</option>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c }}" {% if filter_category == c %}selected{% endif %}>{{ c }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-secondary">Filtern</button>
|
||||
</form>
|
||||
|
||||
{% if files %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dateiname</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Beschreibung</th>
|
||||
<th>Grösse</th>
|
||||
<th>Hochgeladen</th>
|
||||
<th>Tags</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in files %}
|
||||
<tr>
|
||||
<td>{{ f.original_filename }}</td>
|
||||
<td>{{ f.category or '–' }}</td>
|
||||
<td>{{ f.description or '–' }}</td>
|
||||
<td>{{ (f.size_bytes / 1024)|round(1) }} KB</td>
|
||||
<td>{{ f.created_at }} ({{ f.uploader }})</td>
|
||||
<td>{% for tag in f.tags %}<span class="tag">{{ tag }}</span>{% endfor %}</td>
|
||||
<td><a href="/files/{{ f.id }}/download" class="btn btn-sm btn-primary">Download</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty-state">Noch keine Dateien hochgeladen.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{% block page_title %}AzA Intern{% endblock %}{% endblock %}
|
||||
{% block body %}
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<h1>AzA Intern</h1>
|
||||
<small>Internes Portal</small>
|
||||
</div>
|
||||
<nav>
|
||||
<a href="/" {% if active == 'dashboard' %}class="active"{% endif %}>Dashboard</a>
|
||||
<a href="/tasks" {% if active == 'tasks' %}class="active"{% endif %}>Aufgaben</a>
|
||||
<a href="/files" {% if active == 'files' %}class="active"{% endif %}>Dateien</a>
|
||||
<a href="/notes" {% if active == 'notes' %}class="active"{% endif %}>Recherche / Notizen</a>
|
||||
<a href="/search" {% if active == 'search' %}class="active"{% endif %}>Suche</a>
|
||||
{% if user and user.role == 'admin' %}
|
||||
<a href="/admin" {% if active == 'admin' %}class="active"{% endif %}>Admin</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<div class="sidebar-user">
|
||||
{{ user.username }} ({{ user.role }})<br>
|
||||
<form method="post" action="/logout" style="margin-top:0.5rem">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-secondary btn-sm">Abmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Login – AzA Intern{% endblock %}
|
||||
{% block body %}
|
||||
<div class="login-page">
|
||||
<div class="login-box">
|
||||
<h1>AzA Intern</h1>
|
||||
<p class="subtitle">Internes Portal</p>
|
||||
{% if error %}
|
||||
<div class="alert alert-error">Benutzername oder Passwort ungültig.</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="form-group">
|
||||
<label for="username">Benutzername</label>
|
||||
<input type="text" id="username" name="username" required autofocus autocomplete="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%">Anmelden</button>
|
||||
</form>
|
||||
<p style="margin-top:1rem;font-size:0.8rem;color:#64748b;text-align:center;">
|
||||
2FA ist für eine spätere Version vorgesehen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "layout.html" %}
|
||||
{% set active = 'notes' %}
|
||||
{% block page_title %}{% if note %}Notiz bearbeiten{% else %}Neue Notiz{% endif %} – AzA Intern{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>{% if note %}Notiz bearbeiten{% else %}Neue Notiz{% endif %}</h2>
|
||||
<a href="/notes" class="btn btn-secondary">Zurück</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="post" action="{% if note %}/notes/{{ note.id }}/edit{% else %}/notes/new{% endif %}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="form-group">
|
||||
<label for="title">Titel *</label>
|
||||
<input type="text" id="title" name="title" required value="{{ note.title if note else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="category">Kategorie</label>
|
||||
<select id="category" name="category">
|
||||
<option value="">–</option>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c }}" {% if note and note.category == c %}selected{% endif %}>{{ c }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="body">Inhalt</label>
|
||||
<textarea id="body" name="body" rows="12">{{ note.body if note else '' }}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tags">Tags (kommagetrennt)</label>
|
||||
<input type="text" id="tags" name="tags" value="{{ note.tags_str if note else '' }}">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends "layout.html" %}
|
||||
{% set active = 'notes' %}
|
||||
{% block page_title %}Recherche / Notizen – AzA Intern{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Recherche / Notizen</h2>
|
||||
<a href="/notes/new" class="btn btn-primary">Neue Notiz</a>
|
||||
</div>
|
||||
|
||||
<form method="get" class="search-bar">
|
||||
<select name="category">
|
||||
<option value="">Alle Kategorien</option>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c }}" {% if filter_category == c %}selected{% endif %}>{{ c }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-secondary">Filtern</button>
|
||||
</form>
|
||||
|
||||
{% if notes %}
|
||||
{% for n in notes %}
|
||||
<div class="card">
|
||||
<div class="page-header" style="margin-bottom:0.5rem">
|
||||
<h3 style="font-size:1.1rem">{{ n.title }}</h3>
|
||||
<a href="/notes/{{ n.id }}/edit" class="btn btn-sm btn-secondary">Bearbeiten</a>
|
||||
</div>
|
||||
<p style="font-size:0.85rem;color:#64748b">
|
||||
{{ n.category or '–' }} · {{ n.author }} · {{ n.updated_at }}
|
||||
{% for tag in n.tags %}<span class="tag">{{ tag }}</span>{% endfor %}
|
||||
</p>
|
||||
{% if n.body %}
|
||||
<p style="margin-top:0.75rem;white-space:pre-wrap">{{ n.body }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="empty-state">Noch keine Notizen.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,48 @@
|
||||
{% extends "layout.html" %}
|
||||
{% set active = 'search' %}
|
||||
{% block page_title %}Suche – AzA Intern{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Suche</h2>
|
||||
</div>
|
||||
|
||||
<form method="get" action="/search" class="search-bar">
|
||||
<input type="text" name="q" value="{{ query }}" placeholder="Aufgaben, Dateien, Notizen, Tags…" autofocus>
|
||||
<button type="submit" class="btn btn-primary">Suchen</button>
|
||||
</form>
|
||||
|
||||
{% if query %}
|
||||
<div class="grid-3">
|
||||
<div class="card">
|
||||
<h3>Aufgaben ({{ results.tasks|length }})</h3>
|
||||
{% if results.tasks %}
|
||||
<ul style="list-style:none">
|
||||
{% for t in results.tasks %}
|
||||
<li style="margin-bottom:0.5rem"><a href="/tasks/{{ t.id }}">{{ t.title }}</a> <small>({{ t.status }})</small></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}<p class="empty-state">Keine Treffer.</p>{% endif %}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Dateien ({{ results.files|length }})</h3>
|
||||
{% if results.files %}
|
||||
<ul style="list-style:none">
|
||||
{% for f in results.files %}
|
||||
<li style="margin-bottom:0.5rem"><a href="/files/{{ f.id }}/download">{{ f.original_filename }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}<p class="empty-state">Keine Treffer.</p>{% endif %}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Notizen ({{ results.notes|length }})</h3>
|
||||
{% if results.notes %}
|
||||
<ul style="list-style:none">
|
||||
{% for n in results.notes %}
|
||||
<li style="margin-bottom:0.5rem"><a href="/notes/{{ n.id }}/edit">{{ n.title }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}<p class="empty-state">Keine Treffer.</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,29 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Ersteinrichtung – AzA Intern{% endblock %}
|
||||
{% block body %}
|
||||
<div class="login-page">
|
||||
<div class="login-box">
|
||||
<h1>AzA Intern</h1>
|
||||
<p class="subtitle">Ersteinrichtung – Admin anlegen</p>
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/setup">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="form-group">
|
||||
<label for="username">Admin-Benutzername</label>
|
||||
<input type="text" id="username" name="username" required autofocus minlength="3">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Passwort (min. 8 Zeichen)</label>
|
||||
<input type="password" id="password" name="password" required minlength="8">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password_confirm">Passwort bestätigen</label>
|
||||
<input type="password" id="password_confirm" name="password_confirm" required minlength="8">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%">Admin erstellen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,53 @@
|
||||
{% extends "layout.html" %}
|
||||
{% set active = 'tasks' %}
|
||||
{% block page_title %}{{ task.title }} – AzA Intern{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>{{ task.title }}</h2>
|
||||
<div>
|
||||
<a href="/tasks/{{ task.id }}/edit" class="btn btn-secondary">Bearbeiten</a>
|
||||
<a href="/tasks" class="btn btn-secondary">Zurück</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p><strong>Status:</strong> {{ task.status }} |
|
||||
<strong>Priorität:</strong> {{ task.priority }} |
|
||||
<strong>Kategorie:</strong> {{ task.category or '–' }}</p>
|
||||
<p><strong>Zugewiesen:</strong> {{ task.assigned_username or '–' }} |
|
||||
<strong>Fällig:</strong> {{ task.due_date or '–' }}</p>
|
||||
<p><strong>Erstellt von:</strong> {{ task.creator_username }} |
|
||||
<strong>Aktualisiert:</strong> {{ task.updated_at }}</p>
|
||||
{% if tags %}
|
||||
<p><strong>Tags:</strong> {% for tag in tags %}<span class="tag">{{ tag }}</span>{% endfor %}</p>
|
||||
{% endif %}
|
||||
{% if task.description %}
|
||||
<hr style="margin:1rem 0;border:none;border-top:1px solid #cbd5e1">
|
||||
<p>{{ task.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Kommentare</h3>
|
||||
{% if comments %}
|
||||
<ul class="comment-list">
|
||||
{% for c in comments %}
|
||||
<li>
|
||||
<div class="comment-meta">{{ c.username }} – {{ c.created_at }}</div>
|
||||
{{ c.comment }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="empty-state">Noch keine Kommentare.</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/tasks/{{ task.id }}/comment" style="margin-top:1rem">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="form-group">
|
||||
<label for="comment">Neuer Kommentar</label>
|
||||
<textarea id="comment" name="comment" required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Kommentar hinzufügen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,72 @@
|
||||
{% extends "layout.html" %}
|
||||
{% set active = 'tasks' %}
|
||||
{% block page_title %}{% if task %}Aufgabe bearbeiten{% else %}Neue Aufgabe{% endif %} – AzA Intern{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>{% if task %}Aufgabe bearbeiten{% else %}Neue Aufgabe{% endif %}</h2>
|
||||
<a href="/tasks" class="btn btn-secondary">Zurück</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="post" action="{% if task %}/tasks/{{ task.id }}/edit{% else %}/tasks/new{% endif %}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="form-group">
|
||||
<label for="title">Titel *</label>
|
||||
<input type="text" id="title" name="title" required value="{{ task.title if task else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Beschreibung</label>
|
||||
<textarea id="description" name="description">{{ task.description if task else '' }}</textarea>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" name="status">
|
||||
{% for s in task_statuses %}
|
||||
<option value="{{ s }}" {% if task and task.status == s %}selected{% elif not task and s == 'Neu' %}selected{% endif %}>{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="priority">Priorität</label>
|
||||
<select id="priority" name="priority">
|
||||
{% for p in task_priorities %}
|
||||
<option value="{{ p }}" {% if task and task.priority == p %}selected{% elif not task and p == 'normal' %}selected{% endif %}>{{ p }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label for="category">Kategorie</label>
|
||||
<select id="category" name="category">
|
||||
<option value="">–</option>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c }}" {% if task and task.category == c %}selected{% endif %}>{{ c }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="assigned_to">Zugewiesen an</label>
|
||||
<select id="assigned_to" name="assigned_to">
|
||||
<option value="">–</option>
|
||||
{% for u in users %}
|
||||
<option value="{{ u.id }}" {% if task and task.assigned_to == u.id %}selected{% endif %}>{{ u.username }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label for="due_date">Fälligkeitsdatum</label>
|
||||
<input type="date" id="due_date" name="due_date" value="{{ task.due_date if task and task.due_date else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tags">Tags (kommagetrennt)</label>
|
||||
<input type="text" id="tags" name="tags" value="{{ task.tags_str if task else '' }}" placeholder="z.B. Flyer, Q2">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,56 @@
|
||||
{% extends "layout.html" %}
|
||||
{% set active = 'tasks' %}
|
||||
{% block page_title %}Aufgaben – AzA Intern{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Aufgaben</h2>
|
||||
<a href="/tasks/new" class="btn btn-primary">Neue Aufgabe</a>
|
||||
</div>
|
||||
|
||||
<form method="get" class="search-bar">
|
||||
<select name="status">
|
||||
<option value="">Alle Status</option>
|
||||
{% for s in task_statuses %}
|
||||
<option value="{{ s }}" {% if filter_status == s %}selected{% endif %}>{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="category">
|
||||
<option value="">Alle Kategorien</option>
|
||||
{% for c in categories %}
|
||||
<option value="{{ c }}" {% if filter_category == c %}selected{% endif %}>{{ c }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-secondary">Filtern</button>
|
||||
</form>
|
||||
|
||||
{% if tasks %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Titel</th>
|
||||
<th>Status</th>
|
||||
<th>Priorität</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Zugewiesen</th>
|
||||
<th>Fällig</th>
|
||||
<th>Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in tasks %}
|
||||
<tr>
|
||||
<td><a href="/tasks/{{ t.id }}">{{ t.title }}</a></td>
|
||||
<td>{{ t.status }}</td>
|
||||
<td>{{ t.priority }}</td>
|
||||
<td>{{ t.category or '–' }}</td>
|
||||
<td>{{ t.assigned_username or '–' }}</td>
|
||||
<td>{{ t.due_date or '–' }}</td>
|
||||
<td>{% for tag in t.tags %}<span class="tag">{{ tag }}</span>{% endfor %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty-state">Keine Aufgaben gefunden.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user