1544 lines
53 KiB
Python
1544 lines
53 KiB
Python
|
|
"""
|
|||
|
|
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
|
|||
|
|
import html as html_module
|
|||
|
|
from html.parser import HTMLParser
|
|||
|
|
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, JSONResponse, 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 = 200 * 1024 * 1024
|
|||
|
|
MAX_UPLOAD_MB = 200
|
|||
|
|
|
|||
|
|
ALLOWED_EXTENSIONS = frozenset({
|
|||
|
|
".pdf", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg",
|
|||
|
|
".doc", ".docx", ".odt", ".rtf",
|
|||
|
|
".xls", ".xlsx", ".ods", ".csv",
|
|||
|
|
".ppt", ".pptx", ".odp",
|
|||
|
|
".txt", ".md",
|
|||
|
|
".zip", ".7z", ".rar",
|
|||
|
|
".eml", ".msg",
|
|||
|
|
".xml", ".json",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
BLOCKED_EXTENSIONS = frozenset({
|
|||
|
|
".exe", ".bat", ".cmd", ".ps1", ".sh", ".msi", ".dll",
|
|||
|
|
".js", ".vbs", ".scr", ".jar", ".app", ".dmg",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
UPLOAD_HINT_LINE1 = "Max. 200 MB pro Datei"
|
|||
|
|
UPLOAD_HINT_LINE2 = "Bürodateien, PDFs, Bilder, Tabellen, Präsentationen, Archive und E-Mails erlaubt"
|
|||
|
|
UPLOAD_HINT_LINE3 = "Keine Patientendaten, API-Keys, Passwörter oder Secrets hochladen"
|
|||
|
|
|
|||
|
|
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,
|
|||
|
|
display_name TEXT,
|
|||
|
|
role TEXT NOT NULL DEFAULT 'assistant',
|
|||
|
|
is_active INTEGER NOT NULL DEFAULT 1,
|
|||
|
|
deleted_at TEXT,
|
|||
|
|
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,
|
|||
|
|
task_id INTEGER,
|
|||
|
|
uploaded_by INTEGER NOT NULL,
|
|||
|
|
created_at TEXT NOT NULL,
|
|||
|
|
FOREIGN KEY (uploaded_by) REFERENCES users(id),
|
|||
|
|
FOREIGN KEY (task_id) REFERENCES tasks(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)
|
|||
|
|
);
|
|||
|
|
CREATE TABLE IF NOT EXISTS documents (
|
|||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
title TEXT NOT NULL,
|
|||
|
|
content_html TEXT DEFAULT '',
|
|||
|
|
category TEXT,
|
|||
|
|
task_id INTEGER,
|
|||
|
|
created_by INTEGER NOT NULL,
|
|||
|
|
updated_by INTEGER,
|
|||
|
|
created_at TEXT NOT NULL,
|
|||
|
|
updated_at TEXT NOT NULL,
|
|||
|
|
deleted_at TEXT,
|
|||
|
|
FOREIGN KEY (task_id) REFERENCES tasks(id),
|
|||
|
|
FOREIGN KEY (created_by) REFERENCES users(id),
|
|||
|
|
FOREIGN KEY (updated_by) REFERENCES users(id)
|
|||
|
|
);
|
|||
|
|
""")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def migrate_db() -> None:
|
|||
|
|
with get_db() as conn:
|
|||
|
|
file_cols = {row[1] for row in conn.execute("PRAGMA table_info(files)").fetchall()}
|
|||
|
|
if "task_id" not in file_cols:
|
|||
|
|
conn.execute("ALTER TABLE files ADD COLUMN task_id INTEGER REFERENCES tasks(id)")
|
|||
|
|
user_cols = {row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()}
|
|||
|
|
if "display_name" not in user_cols:
|
|||
|
|
conn.execute("ALTER TABLE users ADD COLUMN display_name TEXT")
|
|||
|
|
if "deleted_at" not in user_cols:
|
|||
|
|
conn.execute("ALTER TABLE users ADD COLUMN deleted_at TEXT")
|
|||
|
|
conn.execute(
|
|||
|
|
"""
|
|||
|
|
CREATE TABLE IF NOT EXISTS documents (
|
|||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
title TEXT NOT NULL,
|
|||
|
|
content_html TEXT DEFAULT '',
|
|||
|
|
category TEXT,
|
|||
|
|
task_id INTEGER,
|
|||
|
|
created_by INTEGER NOT NULL,
|
|||
|
|
updated_by INTEGER,
|
|||
|
|
created_at TEXT NOT NULL,
|
|||
|
|
updated_at TEXT NOT NULL,
|
|||
|
|
deleted_at TEXT,
|
|||
|
|
FOREIGN KEY (task_id) REFERENCES tasks(id),
|
|||
|
|
FOREIGN KEY (created_by) REFERENCES users(id),
|
|||
|
|
FOREIGN KEY (updated_by) REFERENCES users(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, display_name, role, is_active FROM users WHERE id = ? AND deleted_at IS NULL",
|
|||
|
|
(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,
|
|||
|
|
"max_upload_bytes": MAX_UPLOAD_BYTES,
|
|||
|
|
"max_upload_mb": MAX_UPLOAD_MB,
|
|||
|
|
"upload_allowed_extensions": sorted(ALLOWED_EXTENSIONS),
|
|||
|
|
"upload_blocked_extensions": sorted(BLOCKED_EXTENSIONS),
|
|||
|
|
"upload_hint_line1": UPLOAD_HINT_LINE1,
|
|||
|
|
"upload_hint_line2": UPLOAD_HINT_LINE2,
|
|||
|
|
"upload_hint_line3": UPLOAD_HINT_LINE3,
|
|||
|
|
}
|
|||
|
|
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 upload_extension_error(filename: str) -> Optional[str]:
|
|||
|
|
ext = Path(filename or "").suffix.lower()
|
|||
|
|
if not ext:
|
|||
|
|
return "type"
|
|||
|
|
if ext in BLOCKED_EXTENSIONS:
|
|||
|
|
return "blocked"
|
|||
|
|
if ext not in ALLOWED_EXTENSIONS:
|
|||
|
|
return "type"
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def allowed_extension(filename: str) -> bool:
|
|||
|
|
return upload_extension_error(filename) is None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def is_odt_file(filename: str) -> bool:
|
|||
|
|
return Path(filename or "").suffix.lower() == ".odt"
|
|||
|
|
|
|||
|
|
|
|||
|
|
ALLOWED_HTML_TAGS = frozenset({"b", "strong", "i", "em", "u", "p", "br", "ul", "ol", "li", "h1", "h2", "h3", "a"})
|
|||
|
|
ALLOWED_HTML_ATTRS = frozenset({"href", "title", "target"})
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _HtmlSanitizer(HTMLParser):
|
|||
|
|
def __init__(self) -> None:
|
|||
|
|
super().__init__()
|
|||
|
|
self._parts: list[str] = []
|
|||
|
|
|
|||
|
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
|
|||
|
|
tag = tag.lower()
|
|||
|
|
if tag not in ALLOWED_HTML_TAGS:
|
|||
|
|
return
|
|||
|
|
if tag == "br":
|
|||
|
|
self._parts.append("<br>")
|
|||
|
|
return
|
|||
|
|
safe_attrs: list[tuple[str, str]] = []
|
|||
|
|
for key, val in attrs:
|
|||
|
|
key = key.lower()
|
|||
|
|
if key not in ALLOWED_HTML_ATTRS or val is None:
|
|||
|
|
continue
|
|||
|
|
val = val.strip()
|
|||
|
|
if key == "href" and val.lower().startswith(("javascript:", "data:")):
|
|||
|
|
continue
|
|||
|
|
safe_attrs.append((key, html_module.escape(val, quote=True)))
|
|||
|
|
attr_str = "".join(f' {k}="{v}"' for k, v in safe_attrs)
|
|||
|
|
self._parts.append(f"<{tag}{attr_str}>")
|
|||
|
|
|
|||
|
|
def handle_endtag(self, tag: str) -> None:
|
|||
|
|
tag = tag.lower()
|
|||
|
|
if tag in ALLOWED_HTML_TAGS and tag != "br":
|
|||
|
|
self._parts.append(f"</{tag}>")
|
|||
|
|
|
|||
|
|
def handle_data(self, data: str) -> None:
|
|||
|
|
self._parts.append(html_module.escape(data))
|
|||
|
|
|
|||
|
|
def get_html(self) -> str:
|
|||
|
|
return "".join(self._parts)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def sanitize_html(raw: str) -> str:
|
|||
|
|
if not raw:
|
|||
|
|
return ""
|
|||
|
|
cleaned = re.sub(
|
|||
|
|
r"<\s*(script|iframe|object|embed|style)[^>]*>.*?<\s*/\s*\1\s*>",
|
|||
|
|
"",
|
|||
|
|
raw,
|
|||
|
|
flags=re.I | re.S,
|
|||
|
|
)
|
|||
|
|
cleaned = re.sub(r"<\s*(script|iframe|object|embed|style)[^>]*/?\s*>", "", cleaned, flags=re.I)
|
|||
|
|
cleaned = re.sub(r"\s+on\w+\s*=\s*(['\"]).*?\1", "", cleaned, flags=re.I)
|
|||
|
|
cleaned = re.sub(r"\s+on\w+\s*=\s*[^\s>]+", "", cleaned, flags=re.I)
|
|||
|
|
parser = _HtmlSanitizer()
|
|||
|
|
parser.feed(cleaned)
|
|||
|
|
parser.close()
|
|||
|
|
return parser.get_html()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def strip_html_text(raw: str) -> str:
|
|||
|
|
return re.sub(r"<[^>]+>", " ", raw or "").strip()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def all_tasks_for_select() -> list[dict]:
|
|||
|
|
with get_db() as conn:
|
|||
|
|
return [
|
|||
|
|
dict(r)
|
|||
|
|
for r in conn.execute(
|
|||
|
|
"""
|
|||
|
|
SELECT id, title FROM tasks
|
|||
|
|
WHERE status != 'Archiv'
|
|||
|
|
ORDER BY updated_at DESC LIMIT 200
|
|||
|
|
"""
|
|||
|
|
).fetchall()
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_document(doc_id: int, include_deleted: bool = False) -> Optional[dict]:
|
|||
|
|
with get_db() as conn:
|
|||
|
|
q = """
|
|||
|
|
SELECT d.*, c.username AS creator_username, u.username AS updater_username,
|
|||
|
|
t.title AS task_title
|
|||
|
|
FROM documents d
|
|||
|
|
JOIN users c ON c.id = d.created_by
|
|||
|
|
LEFT JOIN users u ON u.id = d.updated_by
|
|||
|
|
LEFT JOIN tasks t ON t.id = d.task_id
|
|||
|
|
WHERE d.id = ?
|
|||
|
|
"""
|
|||
|
|
if not include_deleted:
|
|||
|
|
q += " AND d.deleted_at IS NULL"
|
|||
|
|
row = conn.execute(q, (doc_id,)).fetchone()
|
|||
|
|
return dict(row) if row else None
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def save_uploaded_file(
|
|||
|
|
upload: UploadFile,
|
|||
|
|
user_id: int,
|
|||
|
|
*,
|
|||
|
|
category: str = "",
|
|||
|
|
description: str = "",
|
|||
|
|
tags: str = "",
|
|||
|
|
task_id: Optional[int] = None,
|
|||
|
|
) -> tuple[bool, str, Optional[dict[str, Any]]]:
|
|||
|
|
orig = safe_filename(upload.filename or "file")
|
|||
|
|
ext_err = upload_extension_error(orig)
|
|||
|
|
if ext_err == "blocked":
|
|||
|
|
return False, "blocked", None
|
|||
|
|
if ext_err == "type":
|
|||
|
|
return False, "type", None
|
|||
|
|
content = await upload.read()
|
|||
|
|
if len(content) > MAX_UPLOAD_BYTES:
|
|||
|
|
return False, "size", None
|
|||
|
|
stored = f"{uuid.uuid4().hex}{Path(orig).suffix.lower()}"
|
|||
|
|
if ".." in stored or "/" in stored or "\\" in stored:
|
|||
|
|
return False, "invalid", None
|
|||
|
|
dest = UPLOAD_DIR / stored
|
|||
|
|
dest.write_bytes(content)
|
|||
|
|
ts = now_iso()
|
|||
|
|
with get_db() as conn:
|
|||
|
|
if task_id is not None:
|
|||
|
|
task = conn.execute("SELECT id FROM tasks WHERE id = ?", (task_id,)).fetchone()
|
|||
|
|
if not task:
|
|||
|
|
dest.unlink(missing_ok=True)
|
|||
|
|
return False, "task_not_found", None
|
|||
|
|
cur = conn.execute(
|
|||
|
|
"""
|
|||
|
|
INSERT INTO files (original_filename, stored_filename, content_type, size_bytes,
|
|||
|
|
category, description, task_id, uploaded_by, created_at)
|
|||
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|||
|
|
""",
|
|||
|
|
(
|
|||
|
|
orig,
|
|||
|
|
stored,
|
|||
|
|
upload.content_type or "application/octet-stream",
|
|||
|
|
len(content),
|
|||
|
|
category or None,
|
|||
|
|
description.strip() or None,
|
|||
|
|
task_id,
|
|||
|
|
user_id,
|
|||
|
|
ts,
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
file_id = int(cur.lastrowid)
|
|||
|
|
set_item_tags(conn, "file", file_id, tags)
|
|||
|
|
if task_id is not None:
|
|||
|
|
conn.execute("UPDATE tasks SET updated_at = ? WHERE id = ?", (ts, task_id))
|
|||
|
|
row = conn.execute(
|
|||
|
|
"""
|
|||
|
|
SELECT f.*, u.username AS uploader
|
|||
|
|
FROM files f JOIN users u ON u.id = f.uploaded_by
|
|||
|
|
WHERE f.id = ?
|
|||
|
|
""",
|
|||
|
|
(file_id,),
|
|||
|
|
).fetchone()
|
|||
|
|
file_data = dict(row) if row else None
|
|||
|
|
if file_data:
|
|||
|
|
with get_db() as conn:
|
|||
|
|
file_data["tags"] = get_item_tags(conn, "file", file_id)
|
|||
|
|
return True, "ok", file_data
|
|||
|
|
|
|||
|
|
|
|||
|
|
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, display_name, role, is_active, created_at FROM users WHERE deleted_at IS NULL"
|
|||
|
|
if active_only:
|
|||
|
|
q += " AND is_active = 1"
|
|||
|
|
q += " ORDER BY username"
|
|||
|
|
return [dict(r) for r in conn.execute(q).fetchall()]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get_user_profile(user_id: int) -> Optional[dict[str, Any]]:
|
|||
|
|
with get_db() as conn:
|
|||
|
|
row = conn.execute(
|
|||
|
|
"SELECT id, username, display_name, role, is_active, created_at, updated_at FROM users WHERE id = ? AND deleted_at IS NULL",
|
|||
|
|
(user_id,),
|
|||
|
|
).fetchone()
|
|||
|
|
return dict(row) if row else None
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.on_event("startup")
|
|||
|
|
def on_startup() -> None:
|
|||
|
|
init_db()
|
|||
|
|
migrate_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 = ? AND deleted_at IS NULL",
|
|||
|
|
(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)
|
|||
|
|
task_files = conn.execute(
|
|||
|
|
"""
|
|||
|
|
SELECT f.*, u.username AS uploader
|
|||
|
|
FROM files f JOIN users u ON u.id = f.uploaded_by
|
|||
|
|
WHERE f.task_id = ?
|
|||
|
|
ORDER BY f.created_at DESC
|
|||
|
|
""",
|
|||
|
|
(task_id,),
|
|||
|
|
).fetchall()
|
|||
|
|
task_files_list = [dict(r) for r in task_files]
|
|||
|
|
for tf in task_files_list:
|
|||
|
|
tf["tags"] = get_item_tags(conn, "file", tf["id"])
|
|||
|
|
task_documents = conn.execute(
|
|||
|
|
"""
|
|||
|
|
SELECT d.id, d.title, d.category, d.updated_at, c.username AS creator_username
|
|||
|
|
FROM documents d
|
|||
|
|
JOIN users c ON c.id = d.created_by
|
|||
|
|
WHERE d.task_id = ? AND d.deleted_at IS NULL
|
|||
|
|
ORDER BY d.updated_at DESC
|
|||
|
|
""",
|
|||
|
|
(task_id,),
|
|||
|
|
).fetchall()
|
|||
|
|
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(),
|
|||
|
|
task_files=task_files_list,
|
|||
|
|
task_documents=[dict(r) for r in task_documents],
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@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(""),
|
|||
|
|
task_id: str = Form(""),
|
|||
|
|
csrf_token: str = Form(...),
|
|||
|
|
):
|
|||
|
|
user = require_login(request)
|
|||
|
|
verify_csrf(request, csrf_token)
|
|||
|
|
tid = int(task_id) if task_id.isdigit() else None
|
|||
|
|
ok, err, _ = await save_uploaded_file(
|
|||
|
|
upload, user["id"], category=category, description=description, tags=tags, task_id=tid,
|
|||
|
|
)
|
|||
|
|
if not ok:
|
|||
|
|
if tid:
|
|||
|
|
return RedirectResponse(f"/tasks/{tid}?error={err}", status_code=303)
|
|||
|
|
return RedirectResponse(f"/files?error={err}", status_code=303)
|
|||
|
|
if tid:
|
|||
|
|
return RedirectResponse(f"/tasks/{tid}?upload=ok", status_code=303)
|
|||
|
|
return RedirectResponse("/files?upload=ok", status_code=303)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.post("/api/files/upload")
|
|||
|
|
async def api_file_upload(
|
|||
|
|
request: Request,
|
|||
|
|
upload: UploadFile = File(...),
|
|||
|
|
category: str = Form(""),
|
|||
|
|
description: str = Form(""),
|
|||
|
|
tags: str = Form(""),
|
|||
|
|
task_id: str = Form(""),
|
|||
|
|
csrf_token: str = Form(...),
|
|||
|
|
):
|
|||
|
|
user = require_login(request)
|
|||
|
|
verify_csrf(request, csrf_token)
|
|||
|
|
tid = int(task_id) if task_id.isdigit() else None
|
|||
|
|
ok, err, file_data = await save_uploaded_file(
|
|||
|
|
upload, user["id"], category=category, description=description, tags=tags, task_id=tid,
|
|||
|
|
)
|
|||
|
|
if not ok:
|
|||
|
|
messages = {
|
|||
|
|
"blocked": "Dieser Dateityp ist aus Sicherheitsgründen nicht erlaubt.",
|
|||
|
|
"type": "Dateityp nicht erlaubt.",
|
|||
|
|
"size": f"Datei zu gross (max. {MAX_UPLOAD_MB} MB).",
|
|||
|
|
"task_not_found": "Aufgabe nicht gefunden.",
|
|||
|
|
"invalid": "Ungültiger Dateiname.",
|
|||
|
|
}
|
|||
|
|
return JSONResponse({"ok": False, "error": err, "message": messages.get(err, "Upload fehlgeschlagen.")})
|
|||
|
|
return JSONResponse({"ok": True, "file": file_data})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@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("/files/{file_id}/edit", response_class=HTMLResponse)
|
|||
|
|
def file_edit_page(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 f.*, u.username AS uploader
|
|||
|
|
FROM files f JOIN users u ON u.id = f.uploaded_by
|
|||
|
|
WHERE f.id = ?
|
|||
|
|
""",
|
|||
|
|
(file_id,),
|
|||
|
|
).fetchone()
|
|||
|
|
if not row:
|
|||
|
|
raise HTTPException(status_code=404)
|
|||
|
|
file_info = dict(row)
|
|||
|
|
odt = is_odt_file(file_info["original_filename"])
|
|||
|
|
return templates.TemplateResponse(
|
|||
|
|
"file_edit.html",
|
|||
|
|
template_ctx(request, user, file=file_info, is_odt=odt),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@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("/documents", response_class=HTMLResponse)
|
|||
|
|
def documents_list(request: Request):
|
|||
|
|
user = get_current_user(request)
|
|||
|
|
if not user:
|
|||
|
|
return RedirectResponse("/login", status_code=303)
|
|||
|
|
with get_db() as conn:
|
|||
|
|
docs = [
|
|||
|
|
dict(r)
|
|||
|
|
for r in conn.execute(
|
|||
|
|
"""
|
|||
|
|
SELECT d.id, d.title, d.category, d.updated_at, d.created_at,
|
|||
|
|
c.username AS creator_username, t.title AS task_title, d.task_id
|
|||
|
|
FROM documents d
|
|||
|
|
JOIN users c ON c.id = d.created_by
|
|||
|
|
LEFT JOIN tasks t ON t.id = d.task_id
|
|||
|
|
WHERE d.deleted_at IS NULL
|
|||
|
|
ORDER BY d.updated_at DESC
|
|||
|
|
"""
|
|||
|
|
).fetchall()
|
|||
|
|
]
|
|||
|
|
return templates.TemplateResponse(
|
|||
|
|
"documents_list.html",
|
|||
|
|
template_ctx(request, user, documents=docs, message=request.query_params.get("msg")),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.get("/documents/new", response_class=HTMLResponse)
|
|||
|
|
def document_new_page(request: Request, task_id: str = ""):
|
|||
|
|
user = get_current_user(request)
|
|||
|
|
if not user:
|
|||
|
|
return RedirectResponse("/login", status_code=303)
|
|||
|
|
pre_task = int(task_id) if task_id.isdigit() else None
|
|||
|
|
return templates.TemplateResponse(
|
|||
|
|
"document_new.html",
|
|||
|
|
template_ctx(request, user, tasks=all_tasks_for_select(), pre_task_id=pre_task),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.post("/documents/new")
|
|||
|
|
def document_create(
|
|||
|
|
request: Request,
|
|||
|
|
title: str = Form(...),
|
|||
|
|
category: str = Form(""),
|
|||
|
|
task_id: str = Form(""),
|
|||
|
|
csrf_token: str = Form(...),
|
|||
|
|
):
|
|||
|
|
user = require_login(request)
|
|||
|
|
verify_csrf(request, csrf_token)
|
|||
|
|
title = title.strip() or "Unbenanntes Dokument"
|
|||
|
|
tid = int(task_id) if task_id.isdigit() else None
|
|||
|
|
ts = now_iso()
|
|||
|
|
with get_db() as conn:
|
|||
|
|
if tid is not None:
|
|||
|
|
task = conn.execute("SELECT id FROM tasks WHERE id = ?", (tid,)).fetchone()
|
|||
|
|
if not task:
|
|||
|
|
tid = None
|
|||
|
|
cur = conn.execute(
|
|||
|
|
"""
|
|||
|
|
INSERT INTO documents (title, content_html, category, task_id, created_by, updated_by, created_at, updated_at)
|
|||
|
|
VALUES (?, '', ?, ?, ?, ?, ?, ?)
|
|||
|
|
""",
|
|||
|
|
(title, category or None, tid, user["id"], user["id"], ts, ts),
|
|||
|
|
)
|
|||
|
|
doc_id = int(cur.lastrowid)
|
|||
|
|
return RedirectResponse(f"/documents/{doc_id}", status_code=303)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.get("/documents/{document_id}", response_class=HTMLResponse)
|
|||
|
|
def document_edit_page(request: Request, document_id: int):
|
|||
|
|
user = get_current_user(request)
|
|||
|
|
if not user:
|
|||
|
|
return RedirectResponse("/login", status_code=303)
|
|||
|
|
doc = get_document(document_id)
|
|||
|
|
if not doc:
|
|||
|
|
raise HTTPException(status_code=404)
|
|||
|
|
return templates.TemplateResponse(
|
|||
|
|
"document_edit.html",
|
|||
|
|
template_ctx(request, user, document=doc, tasks=all_tasks_for_select()),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.post("/api/documents/{document_id}/save")
|
|||
|
|
async def api_document_save(
|
|||
|
|
request: Request,
|
|||
|
|
document_id: int,
|
|||
|
|
title: str = Form(...),
|
|||
|
|
content_html: str = Form(""),
|
|||
|
|
category: str = Form(""),
|
|||
|
|
task_id: str = Form(""),
|
|||
|
|
csrf_token: str = Form(...),
|
|||
|
|
):
|
|||
|
|
user = require_login(request)
|
|||
|
|
verify_csrf(request, csrf_token)
|
|||
|
|
doc = get_document(document_id)
|
|||
|
|
if not doc:
|
|||
|
|
return JSONResponse({"ok": False, "error": "not_found", "message": "Dokument nicht gefunden."}, status_code=404)
|
|||
|
|
title = title.strip() or "Unbenanntes Dokument"
|
|||
|
|
tid = int(task_id) if task_id.isdigit() else None
|
|||
|
|
if tid is not None:
|
|||
|
|
with get_db() as conn:
|
|||
|
|
task = conn.execute("SELECT id FROM tasks WHERE id = ?", (tid,)).fetchone()
|
|||
|
|
if not task:
|
|||
|
|
tid = None
|
|||
|
|
safe_html = sanitize_html(content_html)
|
|||
|
|
ts = now_iso()
|
|||
|
|
with get_db() as conn:
|
|||
|
|
conn.execute(
|
|||
|
|
"""
|
|||
|
|
UPDATE documents SET title=?, content_html=?, category=?, task_id=?,
|
|||
|
|
updated_by=?, updated_at=? WHERE id=? AND deleted_at IS NULL
|
|||
|
|
""",
|
|||
|
|
(title, safe_html, category or None, tid, user["id"], ts, document_id),
|
|||
|
|
)
|
|||
|
|
return JSONResponse({
|
|||
|
|
"ok": True,
|
|||
|
|
"updated_at": ts,
|
|||
|
|
"title": title,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.post("/documents/{document_id}/delete")
|
|||
|
|
def document_delete(
|
|||
|
|
request: Request,
|
|||
|
|
document_id: int,
|
|||
|
|
csrf_token: str = Form(...),
|
|||
|
|
):
|
|||
|
|
user = require_login(request)
|
|||
|
|
verify_csrf(request, csrf_token)
|
|||
|
|
doc = get_document(document_id)
|
|||
|
|
if not doc:
|
|||
|
|
raise HTTPException(status_code=404)
|
|||
|
|
ts = now_iso()
|
|||
|
|
with get_db() as conn:
|
|||
|
|
conn.execute(
|
|||
|
|
"UPDATE documents SET deleted_at = ?, updated_by = ?, updated_at = ? WHERE id = ?",
|
|||
|
|
(ts, user["id"], ts, document_id),
|
|||
|
|
)
|
|||
|
|
return RedirectResponse("/documents?msg=deleted", 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": [], "documents": []}
|
|||
|
|
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()
|
|||
|
|
]
|
|||
|
|
doc_rows = conn.execute(
|
|||
|
|
"""
|
|||
|
|
SELECT id, title, category, updated_at, content_html FROM documents
|
|||
|
|
WHERE deleted_at IS NULL
|
|||
|
|
AND (title LIKE ? OR category LIKE ? OR content_html LIKE ?)
|
|||
|
|
ORDER BY updated_at DESC LIMIT 30
|
|||
|
|
""",
|
|||
|
|
(like, like, like),
|
|||
|
|
).fetchall()
|
|||
|
|
results["documents"] = [
|
|||
|
|
{k: r[k] for k in ("id", "title", "category", "updated_at")}
|
|||
|
|
for r in doc_rows
|
|||
|
|
]
|
|||
|
|
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, display_name, role, is_active, deleted_at, 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.get("/profile", response_class=HTMLResponse)
|
|||
|
|
def profile_page(request: Request):
|
|||
|
|
user = get_current_user(request)
|
|||
|
|
if not user:
|
|||
|
|
return RedirectResponse("/login", status_code=303)
|
|||
|
|
profile = get_user_profile(user["id"])
|
|||
|
|
if not profile:
|
|||
|
|
return RedirectResponse("/login", status_code=303)
|
|||
|
|
return templates.TemplateResponse(
|
|||
|
|
"profile.html",
|
|||
|
|
template_ctx(request, user, profile=profile, message=request.query_params.get("msg")),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.post("/profile/update")
|
|||
|
|
def profile_update(
|
|||
|
|
request: Request,
|
|||
|
|
display_name: str = Form(""),
|
|||
|
|
csrf_token: str = Form(...),
|
|||
|
|
):
|
|||
|
|
user = require_login(request)
|
|||
|
|
verify_csrf(request, csrf_token)
|
|||
|
|
name = display_name.strip() or None
|
|||
|
|
ts = now_iso()
|
|||
|
|
with get_db() as conn:
|
|||
|
|
conn.execute(
|
|||
|
|
"UPDATE users SET display_name = ?, updated_at = ? WHERE id = ?",
|
|||
|
|
(name, ts, user["id"]),
|
|||
|
|
)
|
|||
|
|
return RedirectResponse("/profile?msg=profile_saved", status_code=303)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.post("/profile/password")
|
|||
|
|
def profile_password(
|
|||
|
|
request: Request,
|
|||
|
|
current_password: str = Form(...),
|
|||
|
|
new_password: str = Form(...),
|
|||
|
|
new_password_confirm: str = Form(...),
|
|||
|
|
csrf_token: str = Form(...),
|
|||
|
|
):
|
|||
|
|
user = require_login(request)
|
|||
|
|
verify_csrf(request, csrf_token)
|
|||
|
|
if new_password != new_password_confirm:
|
|||
|
|
return RedirectResponse("/profile?msg=password_mismatch", status_code=303)
|
|||
|
|
if len(new_password) < 8:
|
|||
|
|
return RedirectResponse("/profile?msg=password_short", status_code=303)
|
|||
|
|
with get_db() as conn:
|
|||
|
|
row = conn.execute("SELECT password_hash FROM users WHERE id = ?", (user["id"],)).fetchone()
|
|||
|
|
if not row or not verify_password(current_password, row["password_hash"]):
|
|||
|
|
return RedirectResponse("/profile?msg=password_wrong", status_code=303)
|
|||
|
|
conn.execute(
|
|||
|
|
"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?",
|
|||
|
|
(hash_password(new_password), now_iso(), user["id"]),
|
|||
|
|
)
|
|||
|
|
return RedirectResponse("/profile?msg=password_changed", status_code=303)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.post("/admin/users/new")
|
|||
|
|
def admin_create_user(
|
|||
|
|
request: Request,
|
|||
|
|
username: str = Form(...),
|
|||
|
|
password: str = Form(...),
|
|||
|
|
display_name: 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, display_name, role, is_active, created_at, updated_at)
|
|||
|
|
VALUES (?, ?, ?, ?, 1, ?, ?)
|
|||
|
|
""",
|
|||
|
|
(username, hash_password(password), display_name.strip() or None, 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}/edit")
|
|||
|
|
def admin_edit_user(
|
|||
|
|
request: Request,
|
|||
|
|
user_id: int,
|
|||
|
|
display_name: str = Form(""),
|
|||
|
|
role: str = Form("assistant"),
|
|||
|
|
password: str = Form(""),
|
|||
|
|
csrf_token: str = Form(...),
|
|||
|
|
):
|
|||
|
|
admin = require_admin(request)
|
|||
|
|
verify_csrf(request, csrf_token)
|
|||
|
|
if role not in ROLES:
|
|||
|
|
role = "assistant"
|
|||
|
|
if user_id == admin["id"] and role != "admin":
|
|||
|
|
return RedirectResponse("/admin?msg=self_role", status_code=303)
|
|||
|
|
if password and len(password) < 8:
|
|||
|
|
return RedirectResponse("/admin?msg=invalid", status_code=303)
|
|||
|
|
ts = now_iso()
|
|||
|
|
with get_db() as conn:
|
|||
|
|
row = conn.execute("SELECT id FROM users WHERE id = ? AND deleted_at IS NULL", (user_id,)).fetchone()
|
|||
|
|
if not row:
|
|||
|
|
raise HTTPException(status_code=404)
|
|||
|
|
if password:
|
|||
|
|
conn.execute(
|
|||
|
|
"UPDATE users SET display_name=?, role=?, password_hash=?, updated_at=? WHERE id=?",
|
|||
|
|
(display_name.strip() or None, role, hash_password(password), ts, user_id),
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
conn.execute(
|
|||
|
|
"UPDATE users SET display_name=?, role=?, updated_at=? WHERE id=?",
|
|||
|
|
(display_name.strip() or None, role, ts, user_id),
|
|||
|
|
)
|
|||
|
|
return RedirectResponse("/admin?msg=updated", 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 = ? AND deleted_at IS NULL", (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)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@app.post("/admin/users/{user_id}/delete")
|
|||
|
|
def admin_delete_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 id FROM users WHERE id = ? AND deleted_at IS NULL", (user_id,)).fetchone()
|
|||
|
|
if not row:
|
|||
|
|
raise HTTPException(status_code=404)
|
|||
|
|
conn.execute(
|
|||
|
|
"UPDATE users SET is_active = 0, deleted_at = ?, updated_at = ? WHERE id = ?",
|
|||
|
|
(ts, ts, user_id),
|
|||
|
|
)
|
|||
|
|
return RedirectResponse("/admin?msg=deleted", status_code=303)
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
import uvicorn
|
|||
|
|
uvicorn.run("app:app", host="127.0.0.1", port=8088, reload=True)
|