This commit is contained in:
2026-05-23 21:31:34 +02:00
parent 51b5ddc6f2
commit 641bb10479
6155 changed files with 3775717 additions and 291 deletions

View File

@@ -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"

View File

@@ -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=

View File

@@ -0,0 +1,6 @@
uploads/
backups/
intern.db
.env
__pycache__/
*.pyc

View File

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

View File

@@ -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"]

View File

@@ -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).

View File

@@ -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.

View File

@@ -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)

View File

@@ -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:

View File

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

View File

@@ -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;
}
}

View File

@@ -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 %}

View File

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

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 }} &nbsp;|&nbsp;
<strong>Priorität:</strong> {{ task.priority }} &nbsp;|&nbsp;
<strong>Kategorie:</strong> {{ task.category or '' }}</p>
<p><strong>Zugewiesen:</strong> {{ task.assigned_username or '' }} &nbsp;|&nbsp;
<strong>Fällig:</strong> {{ task.due_date or '' }}</p>
<p><strong>Erstellt von:</strong> {{ task.creator_username }} &nbsp;|&nbsp;
<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 %}

View File

@@ -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 %}

View File

@@ -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 %}