From ecab0fe6b64e06a0b05d19c86079de8ab8f2a5d3 Mon Sep 17 00:00:00 2001 From: DS | Schubert Date: Fri, 3 Jul 2026 09:14:56 +0200 Subject: [PATCH 01/22] feat(customers): add initial admin bootstrap and csv import --- .env.example | 14 + ARCHITECTURE.md | 56 +++ CONTRIBUTING.md | 28 ++ ROADMAP.md | 10 + backend/hermes/.env.example | 9 + backend/hermes/app/api/auth.py | 2 + backend/hermes/app/api/customers.py | 65 ++- backend/hermes/app/core/config.py | 5 + backend/hermes/app/main.py | 2 + backend/hermes/app/schemas/customer_import.py | 49 +++ backend/hermes/app/schemas/rbac.py | 2 + backend/hermes/app/services/audit_service.py | 2 + .../app/services/customer_import_service.py | 407 ++++++++++++++++++ .../app/services/initial_admin_bootstrap.py | 82 ++++ backend/hermes/docker-compose.yml | 7 +- docker-compose.yml | 5 + .../app/api/customers/import/commit/route.ts | 14 + .../app/api/customers/import/preview/route.ts | 14 + .../api/customers/import/template/route.ts | 40 ++ frontend/athena/app/customers/page.tsx | 44 +- frontend/athena/components/Header.tsx | 60 ++- .../customers/CustomerImportDialog.tsx | 177 ++++++++ .../customers/ImportPreviewTable.tsx | 56 +++ .../customers/ImportSummaryCard.tsx | 24 ++ frontend/athena/lib/server/hermes-proxy.ts | 10 +- frontend/athena/types/customer.ts | 45 ++ frontend/athena/types/rbac.ts | 2 + 27 files changed, 1197 insertions(+), 34 deletions(-) create mode 100644 .env.example create mode 100644 backend/hermes/app/schemas/customer_import.py create mode 100644 backend/hermes/app/services/customer_import_service.py create mode 100644 backend/hermes/app/services/initial_admin_bootstrap.py create mode 100644 frontend/athena/app/api/customers/import/commit/route.ts create mode 100644 frontend/athena/app/api/customers/import/preview/route.ts create mode 100644 frontend/athena/app/api/customers/import/template/route.ts create mode 100644 frontend/athena/components/customers/CustomerImportDialog.tsx create mode 100644 frontend/athena/components/customers/ImportPreviewTable.tsx create mode 100644 frontend/athena/components/customers/ImportSummaryCard.tsx diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3cf62ea --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +DATABASE_URL=postgresql+psycopg://olympus:change-me@postgres:5432/olympus +SECRET_KEY=change-me +ACCESS_TOKEN_EXPIRE_MINUTES=60 +JWT_ISSUER=hermes +LOG_LEVEL=INFO +AUTH_COOKIE_SECURE=false +ATHENA_PUBLIC_ORIGIN=http://localhost:3001 + +# Optional fuer neue Installationen ohne aktive Benutzer. +INITIAL_ADMIN_USERNAME= +INITIAL_ADMIN_EMAIL= +INITIAL_ADMIN_PASSWORD= +INITIAL_ADMIN_FIRST_NAME= +INITIAL_ADMIN_LAST_NAME= diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 870d764..1cb2ecc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -93,6 +93,12 @@ Hermes setzt kein Cookie. 2. Athena loescht das HttpOnly-Cookie. 3. Der Benutzer wird zur Login-Seite gefuehrt. +### Aktueller Benutzer + +Athena stellt `GET /api/me` bereit und ruft dafuer serverseitig Hermes `GET /auth/me` auf. Der Header laedt den aktuellen Benutzer ausschliesslich ueber diese BFF-Route und zeigt bevorzugt Vorname plus Nachname, danach den Benutzernamen und vor dem Laden `Benutzer`. + +Der Browser erhaelt dabei kein Token und ruft Hermes nicht direkt auf. + ### HttpOnly Cookie Das Auth-Cookie wird von Athena gesetzt. @@ -238,6 +244,56 @@ Kunden: - `customers.read` - `customers.create` - `customers.update` + +### Initial Admin Bootstrap + +Hermes prueft beim Startup nach dem RBAC-Seed, ob mindestens ein aktiver Benutzer existiert. Existiert ein aktiver Benutzer, wird kein Benutzer automatisch erstellt. + +Existiert kein aktiver Benutzer, kann Hermes ueber optionale Umgebungsvariablen einen initialen Administrator anlegen: + +- `INITIAL_ADMIN_USERNAME` +- `INITIAL_ADMIN_EMAIL` +- `INITIAL_ADMIN_PASSWORD` +- `INITIAL_ADMIN_FIRST_NAME` +- `INITIAL_ADMIN_LAST_NAME` + +Benutzername, E-Mail und Passwort sind fuer die automatische Anlage erforderlich. Fehlen Werte oder ist die E-Mail ungueltig, startet Hermes weiter und schreibt nur eine Warnung ohne Secrets. Das Passwort wird mit der bestehenden `hash_password`-Funktion gehasht. Der Benutzer erhaelt `role_id` der Systemrolle `administrator` und das Legacy-Feld `role=administrator`. + +### Kundenimport + +Hermes stellt fuer CSV-Importe zwei Endpunkte bereit: + +- `POST /customers/import/preview` +- `POST /customers/import/commit` + +Athena stellt die Browser-BFF-Routen bereit: + +- `POST /api/customers/import/preview` +- `POST /api/customers/import/commit` +- `GET /api/customers/import/template` + +Der Browser sendet Multipart-FormData nur an Athena. Athena leitet die Datei serverseitig mit Bearer Token aus dem HttpOnly-Cookie an Hermes weiter. Hermes begrenzt CSV-Dateien auf 5 MB, bevorzugt UTF-8, erkennt Semikolon und Komma und ignoriert leere Zeilen. + +Importmodi: + +- `create_only`: neue Kunden erstellen, bestehende Kundennummern ueberspringen +- `update_existing`: bestehende Kunden anhand der Kundennummer aktualisieren +- `upsert`: bestehende Kunden aktualisieren und neue Kunden erstellen + +RBAC: + +- Preview benoetigt `customers.read` +- Commit mit `create_only` benoetigt `customers.create` +- Commit mit `update_existing` benoetigt `customers.update` +- Commit mit `upsert` benoetigt `customers.create` und `customers.update` + +CSV-Spalten: + +```text +customer_number;company_name;legal_name;customer_type;status;industry;website;email;phone;tax_number;vat_id;notes;address_type;street;postal_code;city;state;country;address_is_primary;contact_first_name;contact_last_name;contact_position;contact_email;contact_phone;contact_mobile;contact_is_primary;contact_notes +``` + +Pflichtfeld ist `company_name`. Wenn `customer_number`, `customer_type` oder `status` fehlen, erzeugt der Import fuer neue Kunden eine Kundennummer bzw. nutzt produktive Defaults und weist in der Preview darauf hin. Vollstaendige CSV-Inhalte werden nicht im Audit Log gespeichert. - `customers.delete` Projekte: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4e323d..d64d2c8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,6 +105,7 @@ uv run alembic upgrade head - Erfolg und Fehler in mutierenden CRUD-Flows ueber den Toast-Provider melden. - Keine Browser-Dialoge wie `alert()` oder `confirm()`. - Keine Tokens in Browser-JavaScript speichern. +- Datei-Uploads vom Browser laufen ueber Athena-BFF-Routen und werden serverseitig an Hermes weitergeleitet. ### Backend @@ -118,6 +119,7 @@ uv run alembic upgrade head - Berechtigungen mit `require_permission`, `require_any_permission` oder `require_all_permissions` pruefen. - Mutierende Kernaktionen mit Audit Logs erfassen, sofern fachlich relevant. - Sensible Felder vor Persistenz in Logs oder Audit-Daten maskieren. +- Import- und Bootstrap-Flows duerfen keine Passwoerter, Tokens, Secrets oder vollstaendige CSV-Inhalte loggen. ### Allgemein @@ -143,6 +145,7 @@ Diese Regeln sind verbindlich: - Frontend-Permissions dienen nur der UI und ersetzen keine Backend-Pruefung. - Audit Logs werden serverseitig in Hermes geschrieben. - Neue Plattform-Endpunkte sollen das einheitliche API-Response-Format nutzen. +- Header- und Benutzerkontext wird ueber `GET /api/me` geladen; hart codierte Benutzernamen sind unzulaessig. Wenn eine Aufgabe diese Regeln zu verletzen scheint, muss zuerst die Architekturentscheidung geklaert werden. @@ -196,6 +199,31 @@ Ein neues CRM-Modul soll sich am Kundenmodul orientieren: Dashboard-Widgets fuer noch nicht implementierte Module muessen Empty States anzeigen statt hart codierter Beispieldaten. +### Kundenimport + +Kundenimporte muessen die bestehende Kundenarchitektur nutzen und ueber Athena-BFF-Routen laufen. Der Browser darf keine Hermes-URL kennen. + +CSV-Vorgaben: + +- UTF-8 bevorzugen +- Semikolon und Komma unterstuetzen +- leere Zeilen ignorieren +- Fehler und Warnungen pro Zeile zurueckgeben +- Preview darf keine Datenbank-Aenderungen ausloesen +- Commit schreibt Audit Logs ohne CSV-Rohinhalt + +Importmodi: + +- `create_only` +- `update_existing` +- `upsert` + +Die Excel-freundliche Vorlage liegt unter `GET /api/customers/import/template`. + +### Deployment + +Neue Installationen koennen optional ueber `INITIAL_ADMIN_*` einen initialen Administrator anlegen. Diese Variablen werden nur von Hermes gelesen und duerfen nicht im Frontend oder in Logs erscheinen. + ### Neue Permissions Neue Module muessen eigene stabile Permission-Strings erhalten. diff --git a/ROADMAP.md b/ROADMAP.md index 764f49c..f4c7770 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -34,6 +34,16 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - Globale Toasts fuer CRUD-Erfolg und Fehler - Docker-Konfiguration fuer Logging-Level +## v0.5.1 - Bootstrap und Kundenimport + +- Initial-Admin-Bootstrap fuer neue Installationen ohne aktive Benutzer +- Optionale `INITIAL_ADMIN_*` Deployment-Variablen +- Dynamische Header-Benutzeranzeige ueber `GET /api/me` +- CSV-Kundenimport mit Preview und Commit +- Importmodi `create_only`, `update_existing` und `upsert` +- Excel-freundliche CSV-Vorlage ueber Athena +- Audit Log fuer Kundenimporte ohne CSV-Rohdaten + ## v0.6.0 - Projektmodul, geplant - Projektstammdaten diff --git a/backend/hermes/.env.example b/backend/hermes/.env.example index 6035eb9..865387d 100644 --- a/backend/hermes/.env.example +++ b/backend/hermes/.env.example @@ -4,3 +4,12 @@ SECRET_KEY=CHANGE_ME APP_NAME=Hermes API APP_VERSION=0.1.0 +ACCESS_TOKEN_EXPIRE_MINUTES=60 +JWT_ISSUER=hermes +LOG_LEVEL=INFO + +INITIAL_ADMIN_USERNAME= +INITIAL_ADMIN_EMAIL= +INITIAL_ADMIN_PASSWORD= +INITIAL_ADMIN_FIRST_NAME= +INITIAL_ADMIN_LAST_NAME= diff --git a/backend/hermes/app/api/auth.py b/backend/hermes/app/api/auth.py index 0660f5d..1e7df8d 100644 --- a/backend/hermes/app/api/auth.py +++ b/backend/hermes/app/api/auth.py @@ -81,6 +81,8 @@ def login( def me(current_user: User = Depends(get_current_active_user)): return { "id": current_user.id, + "first_name": current_user.first_name, + "last_name": current_user.last_name, "username": current_user.username, "email": current_user.email, "role": current_user.primary_role.name, diff --git a/backend/hermes/app/api/customers.py b/backend/hermes/app/api/customers.py index d26e1be..32158ae 100644 --- a/backend/hermes/app/api/customers.py +++ b/backend/hermes/app/api/customers.py @@ -1,10 +1,10 @@ import logging -from fastapi import APIRouter, Depends, HTTPException, Response, status +from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile, status from sqlalchemy.orm import Session from starlette.requests import Request -from app.core.rbac import require_permission +from app.core.rbac import get_current_active_user, get_user_permission_names, require_permission from app.db.database import get_db from app.models.customer import Customer, CustomerContact from app.models.user import User @@ -17,7 +17,13 @@ from app.schemas.customer import ( CustomerResponse, CustomerUpdate, ) +from app.schemas.customer_import import ( + CustomerImportCommitResponse, + CustomerImportPreviewResponse, + ImportMode, +) from app.services.audit_service import sanitize, write_audit_log +from app.services.customer_import_service import CustomerImportService logger = logging.getLogger(__name__) @@ -26,6 +32,23 @@ router = APIRouter( tags=["Customers"], ) +MAX_IMPORT_SIZE_BYTES = 5 * 1024 * 1024 + + +async def read_import_file(file: UploadFile) -> bytes: + content = await file.read(MAX_IMPORT_SIZE_BYTES + 1) + if len(content) > MAX_IMPORT_SIZE_BYTES: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail="CSV-Datei darf maximal 5 MB groß sein", + ) + if not content: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="CSV-Datei ist leer", + ) + return content + def get_customer_or_404(db: Session, customer_id: int) -> Customer: customer = CustomerRepository.get_by_id(db, customer_id) @@ -116,6 +139,44 @@ def create_customer( return created_customer +@router.post("/import/preview", response_model=CustomerImportPreviewResponse) +async def preview_customer_import( + mode: ImportMode = Form(...), + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("customers.read")), +): + logger.info("customers.import.preview", extra={"actor_user_id": current_user.id, "mode": mode}) + content = await read_import_file(file) + try: + return CustomerImportService.preview(db, content, mode) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + +@router.post("/import/commit", response_model=CustomerImportCommitResponse) +async def commit_customer_import( + request: Request, + mode: ImportMode = Form(...), + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + permissions = get_user_permission_names(current_user) + required_permissions = {"customers.create"} if mode == "create_only" else {"customers.update"} + if mode == "upsert": + required_permissions = {"customers.create", "customers.update"} + if not required_permissions.issubset(permissions): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Keine Berechtigung") + + logger.info("customers.import.commit", extra={"actor_user_id": current_user.id, "mode": mode}) + content = await read_import_file(file) + try: + return CustomerImportService.commit(db, content, mode, actor=current_user, request=request) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + @router.put("/{customer_id}", response_model=CustomerResponse) def update_customer( customer_id: int, diff --git a/backend/hermes/app/core/config.py b/backend/hermes/app/core/config.py index 685d618..92fd66e 100644 --- a/backend/hermes/app/core/config.py +++ b/backend/hermes/app/core/config.py @@ -10,6 +10,11 @@ class Settings(BaseSettings): access_token_expire_minutes: int = 60 jwt_issuer: str = "hermes" log_level: str = "INFO" + initial_admin_username: str | None = None + initial_admin_email: str | None = None + initial_admin_password: str | None = None + initial_admin_first_name: str = "" + initial_admin_last_name: str = "" model_config = SettingsConfigDict( env_file=".env", diff --git a/backend/hermes/app/main.py b/backend/hermes/app/main.py index 2ef7a74..4edba3d 100644 --- a/backend/hermes/app/main.py +++ b/backend/hermes/app/main.py @@ -20,6 +20,7 @@ from app.db.database import SessionLocal from app.db.health import check_database from app.core.logging import configure_logging from app.rbac.seed import seed_rbac +from app.services.initial_admin_bootstrap import bootstrap_initial_admin configure_logging() @@ -73,6 +74,7 @@ def startup_seed_rbac(): db = SessionLocal() try: seed_rbac(db) + bootstrap_initial_admin(db) finally: db.close() diff --git a/backend/hermes/app/schemas/customer_import.py b/backend/hermes/app/schemas/customer_import.py new file mode 100644 index 0000000..d9926f3 --- /dev/null +++ b/backend/hermes/app/schemas/customer_import.py @@ -0,0 +1,49 @@ +from typing import Literal + +from pydantic import BaseModel, Field + +ImportMode = Literal["create_only", "update_existing", "upsert"] +ImportAction = Literal["create", "update", "skip", "error"] + + +class CustomerImportIssue(BaseModel): + row: int + field: str = "" + message: str + + +class CustomerImportPreviewRow(BaseModel): + row: int + customer_number: str = "" + company_name: str = "" + action: ImportAction + errors: list[CustomerImportIssue] = Field(default_factory=list) + warnings: list[CustomerImportIssue] = Field(default_factory=list) + + +class CustomerImportSummary(BaseModel): + total_rows: int + valid_rows: int + error_count: int + warning_count: int + duplicate_count: int + create_count: int + update_count: int + skip_count: int + + +class CustomerImportPreviewResponse(BaseModel): + mode: ImportMode + summary: CustomerImportSummary + rows: list[CustomerImportPreviewRow] + errors: list[CustomerImportIssue] + warnings: list[CustomerImportIssue] + duplicates: list[CustomerImportIssue] + + +class CustomerImportCommitResponse(BaseModel): + mode: ImportMode + summary: CustomerImportSummary + created: int + updated: int + skipped: int diff --git a/backend/hermes/app/schemas/rbac.py b/backend/hermes/app/schemas/rbac.py index fae3cf7..a1b8adf 100644 --- a/backend/hermes/app/schemas/rbac.py +++ b/backend/hermes/app/schemas/rbac.py @@ -68,6 +68,8 @@ class RoleResponse(BaseModel): class CurrentUserResponse(BaseModel): id: int + first_name: str + last_name: str username: str email: str role: str diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py index f981a1d..c415107 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -126,9 +126,11 @@ def action_title(action: str) -> str: "customers.create": "Kunde erstellt", "customers.update": "Kunde bearbeitet", "customers.delete": "Kunde gelöscht", + "customers.import": "Kunden importiert", "customer_contacts.create": "Ansprechpartner erstellt", "customer_contacts.update": "Ansprechpartner bearbeitet", "customer_contacts.delete": "Ansprechpartner gelöscht", + "users.initial_admin_bootstrap": "Initialer Administrator erstellt", } return labels.get(action, action) diff --git a/backend/hermes/app/services/customer_import_service.py b/backend/hermes/app/services/customer_import_service.py new file mode 100644 index 0000000..28c414a --- /dev/null +++ b/backend/hermes/app/services/customer_import_service.py @@ -0,0 +1,407 @@ +import csv +import io +import uuid +from dataclasses import dataclass + +from pydantic import EmailStr, HttpUrl, TypeAdapter, ValidationError +from sqlalchemy.orm import Session + +from app.models.customer import Customer, CustomerAddress, CustomerContact +from app.models.user import User +from app.repositories.customer_repository import CustomerRepository +from app.schemas.customer_import import ( + CustomerImportCommitResponse, + CustomerImportIssue, + CustomerImportPreviewResponse, + CustomerImportPreviewRow, + CustomerImportSummary, + ImportMode, +) +from app.services.audit_service import write_audit_log + +CSV_COLUMNS = [ + "customer_number", + "company_name", + "legal_name", + "customer_type", + "status", + "industry", + "website", + "email", + "phone", + "tax_number", + "vat_id", + "notes", + "address_type", + "street", + "postal_code", + "city", + "state", + "country", + "address_is_primary", + "contact_first_name", + "contact_last_name", + "contact_position", + "contact_email", + "contact_phone", + "contact_mobile", + "contact_is_primary", + "contact_notes", +] + +VALID_STATUSES = {"lead", "active", "inactive", "blocked", "archived"} +VALID_TYPES = {"company", "private", "public_sector", "partner", "supplier"} +VALID_ADDRESS_TYPES = {"billing", "shipping", "primary", "other"} +TRUE_VALUES = {"1", "true", "yes", "ja", "j", "x"} +FALSE_VALUES = {"0", "false", "no", "nein", "n", ""} + +email_adapter = TypeAdapter(EmailStr) +url_adapter = TypeAdapter(HttpUrl) + + +@dataclass +class ParsedImportRow: + row_number: int + data: dict[str, str] + action: str + errors: list[CustomerImportIssue] + warnings: list[CustomerImportIssue] + existing_customer: Customer | None = None + + +def get_customer_import_template() -> str: + return ";".join(CSV_COLUMNS) + "\n" + + +def decode_csv(content: bytes) -> str: + try: + return content.decode("utf-8-sig") + except UnicodeDecodeError as exc: + raise ValueError("CSV-Datei muss UTF-8-kodiert sein") from exc + + +def parse_bool(value: str) -> bool: + normalized = value.strip().lower() + if normalized in TRUE_VALUES: + return True + if normalized in FALSE_VALUES: + return False + return False + + +def parse_bool_default(value: str, default: bool = False) -> bool: + if value.strip() == "": + return default + return parse_bool(value) + + +def sniff_dialect(text: str) -> csv.Dialect: + sample = text[:4096] + try: + return csv.Sniffer().sniff(sample, delimiters=",;") + except csv.Error: + return csv.excel + + +def read_csv_rows(content: bytes) -> list[tuple[int, dict[str, str]]]: + text = decode_csv(content) + reader = csv.DictReader(io.StringIO(text), dialect=sniff_dialect(text)) + rows: list[tuple[int, dict[str, str]]] = [] + + for index, row in enumerate(reader, start=2): + normalized = {column: (row.get(column) or "").strip() for column in CSV_COLUMNS} + if not any(normalized.values()): + continue + rows.append((index, normalized)) + + return rows + + +def validate_email(value: str, row_number: int, field: str, errors: list[CustomerImportIssue]) -> str: + if not value: + return "" + try: + return str(email_adapter.validate_python(value)) + except ValidationError: + errors.append(CustomerImportIssue(row=row_number, field=field, message="E-Mail ist ungültig")) + return value + + +def validate_url(value: str, row_number: int, errors: list[CustomerImportIssue]) -> str: + if not value: + return "" + candidate = value if value.startswith(("http://", "https://")) else f"https://{value}" + try: + return str(url_adapter.validate_python(candidate)) + except ValidationError: + errors.append(CustomerImportIssue(row=row_number, field="website", message="Website ist ungültig")) + return value + + +def planned_action(mode: ImportMode, existing_customer: Customer | None) -> str: + if existing_customer is None: + return "create" if mode in {"create_only", "upsert"} else "skip" + if mode == "create_only": + return "skip" + return "update" + + +class CustomerImportService: + @staticmethod + def preview(db: Session, content: bytes, mode: ImportMode) -> CustomerImportPreviewResponse: + rows = CustomerImportService._parse_and_validate(db, content, mode) + return CustomerImportService._build_preview(mode, rows) + + @staticmethod + def commit( + db: Session, + content: bytes, + mode: ImportMode, + *, + actor: User, + request, + ) -> CustomerImportCommitResponse: + rows = CustomerImportService._parse_and_validate(db, content, mode) + preview = CustomerImportService._build_preview(mode, rows) + if preview.summary.valid_rows == 0: + return CustomerImportCommitResponse( + mode=mode, + summary=preview.summary, + created=0, + updated=0, + skipped=preview.summary.skip_count, + ) + + created = 0 + updated = 0 + skipped = 0 + + for row in rows: + if row.errors or row.action == "error": + continue + if row.action == "skip": + skipped += 1 + continue + if row.action == "create": + CustomerImportService._create_customer(db, row.data) + created += 1 + elif row.action == "update" and row.existing_customer is not None: + CustomerImportService._update_customer(db, row.existing_customer, row.data) + updated += 1 + + db.commit() + write_audit_log( + db, + action="customers.import", + entity_type="customers", + actor=actor, + request=request, + metadata={ + "mode": mode, + "total_rows": preview.summary.total_rows, + "created": created, + "updated": updated, + "skipped": skipped, + "errors": preview.summary.error_count, + "warnings": preview.summary.warning_count, + }, + ) + summary = CustomerImportService._build_preview(mode, rows).summary + return CustomerImportCommitResponse( + mode=mode, + summary=summary, + created=created, + updated=updated, + skipped=skipped, + ) + + @staticmethod + def _parse_and_validate(db: Session, content: bytes, mode: ImportMode) -> list[ParsedImportRow]: + csv_rows = read_csv_rows(content) + number_rows: dict[str, int] = {} + duplicates: set[str] = set() + + for row_number, data in csv_rows: + customer_number = data["customer_number"] + if not customer_number: + continue + if customer_number in number_rows: + duplicates.add(customer_number) + else: + number_rows[customer_number] = row_number + + parsed_rows: list[ParsedImportRow] = [] + for row_number, data in csv_rows: + errors: list[CustomerImportIssue] = [] + warnings: list[CustomerImportIssue] = [] + + if not data["company_name"]: + errors.append(CustomerImportIssue(row=row_number, field="company_name", message="Firmenname ist erforderlich")) + + if not data["customer_type"]: + data["customer_type"] = "company" + warnings.append(CustomerImportIssue(row=row_number, field="customer_type", message="Typ fehlt und wird als company importiert")) + elif data["customer_type"] not in VALID_TYPES: + errors.append(CustomerImportIssue(row=row_number, field="customer_type", message="Typ ist ungültig")) + + if not data["status"]: + data["status"] = "active" + warnings.append(CustomerImportIssue(row=row_number, field="status", message="Status fehlt und wird als active importiert")) + elif data["status"] not in VALID_STATUSES: + errors.append(CustomerImportIssue(row=row_number, field="status", message="Status ist ungültig")) + + if not data["address_type"]: + data["address_type"] = "primary" + elif data["address_type"] not in VALID_ADDRESS_TYPES: + errors.append(CustomerImportIssue(row=row_number, field="address_type", message="Adresstyp ist ungültig")) + + data["email"] = validate_email(data["email"], row_number, "email", errors) + data["contact_email"] = validate_email(data["contact_email"], row_number, "contact_email", errors) + data["website"] = validate_url(data["website"], row_number, errors) + + if data["customer_number"] and data["customer_number"] in duplicates: + errors.append(CustomerImportIssue(row=row_number, field="customer_number", message="Kundennummer ist in der CSV mehrfach vorhanden")) + + if not data["customer_number"] and mode == "update_existing": + errors.append(CustomerImportIssue(row=row_number, field="customer_number", message="Kundennummer ist für update_existing erforderlich")) + + if not data["customer_number"]: + warnings.append(CustomerImportIssue(row=row_number, field="customer_number", message="Kundennummer fehlt und wird beim Erstellen generiert")) + + existing_customer = ( + CustomerRepository.get_by_number(db, data["customer_number"]) + if data["customer_number"] + else None + ) + action = planned_action(mode, existing_customer) + if action == "skip": + reason = "Kunde existiert bereits" if existing_customer is not None else "Kunde existiert nicht" + warnings.append(CustomerImportIssue(row=row_number, field="customer_number", message=f"Zeile wird übersprungen: {reason}")) + + if errors: + action = "error" + + parsed_rows.append( + ParsedImportRow( + row_number=row_number, + data=data, + action=action, + errors=errors, + warnings=warnings, + existing_customer=existing_customer, + ) + ) + + return parsed_rows + + @staticmethod + def _build_preview(mode: ImportMode, rows: list[ParsedImportRow]) -> CustomerImportPreviewResponse: + preview_rows = [ + CustomerImportPreviewRow( + row=row.row_number, + customer_number=row.data["customer_number"], + company_name=row.data["company_name"], + action=row.action, + errors=row.errors, + warnings=row.warnings, + ) + for row in rows + ] + errors = [issue for row in rows for issue in row.errors] + warnings = [issue for row in rows for issue in row.warnings] + duplicates = [issue for issue in errors if issue.field == "customer_number" and "mehrfach" in issue.message] + summary = CustomerImportSummary( + total_rows=len(rows), + valid_rows=sum(1 for row in rows if not row.errors and row.action in {"create", "update"}), + error_count=len(errors), + warning_count=len(warnings), + duplicate_count=len(duplicates), + create_count=sum(1 for row in rows if not row.errors and row.action == "create"), + update_count=sum(1 for row in rows if not row.errors and row.action == "update"), + skip_count=sum(1 for row in rows if not row.errors and row.action == "skip"), + ) + return CustomerImportPreviewResponse( + mode=mode, + summary=summary, + rows=preview_rows, + errors=errors, + warnings=warnings, + duplicates=duplicates, + ) + + @staticmethod + def _customer_number(data: dict[str, str]) -> str: + return data["customer_number"] or f"IMP-{uuid.uuid4().hex[:10].upper()}" + + @staticmethod + def _address(data: dict[str, str]) -> CustomerAddress: + return CustomerAddress( + type=data["address_type"], + street=data["street"], + postal_code=data["postal_code"], + city=data["city"], + state=data["state"], + country=data["country"] or "Deutschland", + is_primary=parse_bool_default(data["address_is_primary"], default=True), + ) + + @staticmethod + def _contact(data: dict[str, str]) -> CustomerContact | None: + if not any(data[key] for key in ("contact_first_name", "contact_last_name", "contact_email", "contact_phone", "contact_mobile")): + return None + return CustomerContact( + first_name=data["contact_first_name"], + last_name=data["contact_last_name"], + position=data["contact_position"], + email=data["contact_email"], + phone=data["contact_phone"], + mobile=data["contact_mobile"], + is_primary=parse_bool(data["contact_is_primary"]), + notes=data["contact_notes"], + ) + + @staticmethod + def _create_customer(db: Session, data: dict[str, str]) -> Customer: + customer = Customer( + customer_number=CustomerImportService._customer_number(data), + company_name=data["company_name"], + legal_name=data["legal_name"], + customer_type=data["customer_type"], + status=data["status"], + industry=data["industry"], + website=data["website"], + email=data["email"], + phone=data["phone"], + tax_number=data["tax_number"], + vat_id=data["vat_id"], + notes=data["notes"], + addresses=[CustomerImportService._address(data)], + ) + contact = CustomerImportService._contact(data) + if contact is not None: + customer.contacts = [contact] + db.add(customer) + return customer + + @staticmethod + def _update_customer(db: Session, customer: Customer, data: dict[str, str]) -> Customer: + customer.company_name = data["company_name"] + customer.legal_name = data["legal_name"] + customer.customer_type = data["customer_type"] + customer.status = data["status"] + customer.industry = data["industry"] + customer.website = data["website"] + customer.email = data["email"] + customer.phone = data["phone"] + customer.tax_number = data["tax_number"] + customer.vat_id = data["vat_id"] + customer.notes = data["notes"] + customer.addresses = [CustomerImportService._address(data)] + contact = CustomerImportService._contact(data) + if contact is not None: + if contact.is_primary: + for existing_contact in customer.contacts: + existing_contact.is_primary = False + customer.contacts.append(contact) + db.add(customer) + return customer diff --git a/backend/hermes/app/services/initial_admin_bootstrap.py b/backend/hermes/app/services/initial_admin_bootstrap.py new file mode 100644 index 0000000..98582ba --- /dev/null +++ b/backend/hermes/app/services/initial_admin_bootstrap.py @@ -0,0 +1,82 @@ +import logging + +from pydantic import EmailStr, TypeAdapter, ValidationError +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.security import hash_password +from app.models.user import User +from app.repositories.rbac_repository import RbacRepository +from app.services.audit_service import write_audit_log + +logger = logging.getLogger(__name__) + +email_adapter = TypeAdapter(EmailStr) + + +def bootstrap_initial_admin(db: Session) -> None: + active_user_exists = db.scalar(select(User.id).where(User.is_active.is_(True)).limit(1)) + if active_user_exists is not None: + logger.info("initial_admin.skipped_active_user_exists") + return + + username = (settings.initial_admin_username or "").strip() + email = (settings.initial_admin_email or "").strip() + password = settings.initial_admin_password or "" + + if not username or not email or not password: + logger.warning( + "initial_admin.not_configured", + extra={"reason": "missing_required_initial_admin_environment"}, + ) + return + + try: + normalized_email = str(email_adapter.validate_python(email)) + except ValidationError: + logger.warning("initial_admin.invalid_email") + return + + administrator_role = RbacRepository.get_role_by_name(db, "administrator") + if administrator_role is None: + logger.warning("initial_admin.missing_administrator_role") + return + + existing_user = db.scalar(select(User).where((User.username == username) | (User.email == normalized_email))) + if existing_user is not None: + logger.info("initial_admin.skipped_user_already_exists") + return + + admin = User( + first_name=settings.initial_admin_first_name.strip(), + last_name=settings.initial_admin_last_name.strip(), + username=username, + email=normalized_email, + password_hash=hash_password(password), + role="administrator", + role_id=administrator_role.id, + is_active=True, + ) + db.add(admin) + db.commit() + db.refresh(admin) + + write_audit_log( + db, + action="users.initial_admin_bootstrap", + entity_type="users", + entity_id=admin.id, + entity_label=admin.username, + actor_username="system", + after_data={ + "id": admin.id, + "username": admin.username, + "email": admin.email, + "role": admin.role, + "role_id": admin.role_id, + "is_active": admin.is_active, + }, + metadata={"source": "initial_admin_bootstrap"}, + ) + logger.info("initial_admin.created", extra={"user_id": admin.id}) diff --git a/backend/hermes/docker-compose.yml b/backend/hermes/docker-compose.yml index c0816c2..3c5b90e 100644 --- a/backend/hermes/docker-compose.yml +++ b/backend/hermes/docker-compose.yml @@ -5,10 +5,15 @@ services: restart: unless-stopped environment: - DATABASE_URL: postgresql+psycopg://olympus:FsFs03285310!!!@olympus-db:5432/olympus + DATABASE_URL: ${DATABASE_URL} APP_NAME: Hermes API APP_VERSION: 0.1.0 SECRET_KEY: ${SECRET_KEY} + INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-} + INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-} + INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-} + INITIAL_ADMIN_FIRST_NAME: ${INITIAL_ADMIN_FIRST_NAME:-} + INITIAL_ADMIN_LAST_NAME: ${INITIAL_ADMIN_LAST_NAME:-} ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} JWT_ISSUER: ${JWT_ISSUER:-hermes} LOG_LEVEL: ${LOG_LEVEL:-INFO} diff --git a/docker-compose.yml b/docker-compose.yml index f0768fa..68d7f53 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,11 @@ services: ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} JWT_ISSUER: ${JWT_ISSUER:-hermes} LOG_LEVEL: ${LOG_LEVEL:-INFO} + INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-} + INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-} + INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-} + INITIAL_ADMIN_FIRST_NAME: ${INITIAL_ADMIN_FIRST_NAME:-} + INITIAL_ADMIN_LAST_NAME: ${INITIAL_ADMIN_LAST_NAME:-} expose: - "8000" diff --git a/frontend/athena/app/api/customers/import/commit/route.ts b/frontend/athena/app/api/customers/import/commit/route.ts new file mode 100644 index 0000000..ca67cb2 --- /dev/null +++ b/frontend/athena/app/api/customers/import/commit/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; + +import { assertSameOrigin } from "@/lib/server/request-guards"; +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/customers/import/commit"); +} diff --git a/frontend/athena/app/api/customers/import/preview/route.ts b/frontend/athena/app/api/customers/import/preview/route.ts new file mode 100644 index 0000000..fcc267f --- /dev/null +++ b/frontend/athena/app/api/customers/import/preview/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; + +import { assertSameOrigin } from "@/lib/server/request-guards"; +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyHermesRequest(request, "/customers/import/preview"); +} diff --git a/frontend/athena/app/api/customers/import/template/route.ts b/frontend/athena/app/api/customers/import/template/route.ts new file mode 100644 index 0000000..1dd0b8d --- /dev/null +++ b/frontend/athena/app/api/customers/import/template/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; + +const columns = [ + "customer_number", + "company_name", + "legal_name", + "customer_type", + "status", + "industry", + "website", + "email", + "phone", + "tax_number", + "vat_id", + "notes", + "address_type", + "street", + "postal_code", + "city", + "state", + "country", + "address_is_primary", + "contact_first_name", + "contact_last_name", + "contact_position", + "contact_email", + "contact_phone", + "contact_mobile", + "contact_is_primary", + "contact_notes", +]; + +export async function GET() { + return new NextResponse(`${columns.join(";")}\n`, { + headers: { + "Content-Disposition": 'attachment; filename="kundenimport-vorlage.csv"', + "Content-Type": "text/csv; charset=utf-8", + }, + }); +} diff --git a/frontend/athena/app/customers/page.tsx b/frontend/athena/app/customers/page.tsx index 69a9e1a..e7df5ed 100644 --- a/frontend/athena/app/customers/page.tsx +++ b/frontend/athena/app/customers/page.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; -import { Edit, Eye, Plus, Trash2 } from "lucide-react"; +import { Edit, Eye, Plus, Trash2, Upload } from "lucide-react"; import ConfirmDialog from "@/components/common/ConfirmDialog"; import DataTable, { type DataTableColumn } from "@/components/common/DataTable"; @@ -10,11 +10,12 @@ import SearchInput from "@/components/common/SearchInput"; import { useToast } from "@/components/common/ToastProvider"; import { Button, buttonVariants } from "@/components/ui/button"; import CustomerFormDialog from "@/components/customers/CustomerFormDialog"; +import CustomerImportDialog from "@/components/customers/CustomerImportDialog"; import CustomerStatusBadge from "@/components/customers/CustomerStatusBadge"; import { api } from "@/lib/api"; import { hasPermission } from "@/lib/permissions"; import type { CurrentUser } from "@/types/rbac"; -import type { Customer, CustomerPayload, CustomerStatus, CustomerType } from "@/types/customer"; +import type { Customer, CustomerImportCommitResponse, CustomerPayload, CustomerStatus, CustomerType } from "@/types/customer"; const pageSize = 10; @@ -64,6 +65,7 @@ export default function CustomersPage() { const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); const [page, setPage] = useState(1); const [formOpen, setFormOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); const [selectedCustomer, setSelectedCustomer] = useState(null); const [deleteCustomer, setDeleteCustomer] = useState(null); @@ -122,6 +124,7 @@ export default function CustomersPage() { const pageCount = Math.max(1, Math.ceil(filteredCustomers.length / pageSize)); const currentPage = Math.min(page, pageCount); const pageCustomers = filteredCustomers.slice((currentPage - 1) * pageSize, currentPage * pageSize); + const canImportCustomers = hasPermission(currentUser, "customers.create") || hasPermission(currentUser, "customers.update"); const columns: DataTableColumn[] = [ { key: "customer_number", label: "Kundennummer", sortable: true, render: (customer) => customer.customer_number }, @@ -221,6 +224,16 @@ export default function CustomersPage() { } } + async function handleImportCommitted(result: CustomerImportCommitResponse) { + showToast({ + type: "success", + title: "Kundenimport abgeschlossen", + description: `${result.created} erstellt, ${result.updated} aktualisiert, ${result.skipped} übersprungen`, + }); + setLoading(true); + await loadCustomers(); + } + return (
@@ -228,12 +241,20 @@ export default function CustomersPage() {

Kunden

{filteredCustomers.length} von {customers.length} Kunden

- {hasPermission(currentUser, "customers.create") && ( - - )} +
+ {canImportCustomers && ( + + )} + {hasPermission(currentUser, "customers.create") && ( + + )} +
@@ -286,6 +307,13 @@ export default function CustomersPage() { onSubmit={saveCustomer} /> + showToast({ type: "error", title: "Kundenimport fehlgeschlagen", description: message })} + /> + (null); - return ( + useEffect(() => { + let active = true; -
+ api.get("/me") + .then((response) => { + if (active) { + setCurrentUser(response.data); + } + }) + .catch(() => { + if (active) { + setCurrentUser(null); + } + }); -

+ return () => { + active = false; + }; + }, []); - Dashboard + const displayName = useMemo(() => { + if (!currentUser) { + return "Benutzer"; + } -

+ const fullName = `${currentUser.first_name} ${currentUser.last_name}`.trim(); + return fullName || currentUser.username || "Benutzer"; + }, [currentUser]); -
- - - - admin.schubert - - - - - -
- -
- - ); + return ( +
+

Dashboard

+
+ {displayName} + +
+
+ ); } diff --git a/frontend/athena/components/customers/CustomerImportDialog.tsx b/frontend/athena/components/customers/CustomerImportDialog.tsx new file mode 100644 index 0000000..3de6ea7 --- /dev/null +++ b/frontend/athena/components/customers/CustomerImportDialog.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { Download, FileUp, Play, Upload } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; + +import ImportPreviewTable from "@/components/customers/ImportPreviewTable"; +import ImportSummaryCard from "@/components/customers/ImportSummaryCard"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { api } from "@/lib/api"; +import type { + CustomerImportCommitResponse, + CustomerImportMode, + CustomerImportPreviewResponse, +} from "@/types/customer"; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + onCommitted: (result: CustomerImportCommitResponse) => void; + onError: (message: string) => void; +}; + +function errorMessage(error: unknown) { + if (typeof error === "object" && error !== null && "response" in error) { + const response = (error as { response?: { data?: { detail?: string } } }).response; + return response?.data?.detail ?? "Import konnte nicht ausgeführt werden"; + } + return "Import konnte nicht ausgeführt werden"; +} + +function buildFormData(file: File, mode: CustomerImportMode) { + const formData = new FormData(); + formData.append("mode", mode); + formData.append("file", file); + return formData; +} + +export default function CustomerImportDialog({ open, onOpenChange, onCommitted, onError }: Props) { + const [file, setFile] = useState(null); + const [mode, setMode] = useState("upsert"); + const [preview, setPreview] = useState(null); + const [pending, setPending] = useState(false); + const [serverError, setServerError] = useState(""); + + async function runPreview() { + if (!file) { + setServerError("Bitte CSV-Datei auswählen"); + return; + } + + setPending(true); + setServerError(""); + try { + const response = await api.post( + "/customers/import/preview", + buildFormData(file, mode), + ); + setPreview(response.data); + } catch (error) { + const message = errorMessage(error); + setServerError(message); + onError(message); + } finally { + setPending(false); + } + } + + async function commitImport() { + if (!file || !preview || preview.summary.valid_rows === 0) { + return; + } + + setPending(true); + setServerError(""); + try { + const response = await api.post( + "/customers/import/commit", + buildFormData(file, mode), + ); + onCommitted(response.data); + setPreview(null); + setFile(null); + onOpenChange(false); + } catch (error) { + const message = errorMessage(error); + setServerError(message); + onError(message); + } finally { + setPending(false); + } + } + + return ( + + + + Kunden importieren + CSV-Datei prüfen und anschließend gültige Zeilen übernehmen. + + +
+
+
+ + { + setFile(event.target.files?.[0] ?? null); + setPreview(null); + setServerError(""); + }} + /> +
+ +
+ + +
+ + + + Vorlage + +
+ + {serverError &&

{serverError}

} + + {preview && ( +
+ + +
+ )} +
+ + + + + + +
+
+ ); +} diff --git a/frontend/athena/components/customers/ImportPreviewTable.tsx b/frontend/athena/components/customers/ImportPreviewTable.tsx new file mode 100644 index 0000000..23f6027 --- /dev/null +++ b/frontend/athena/components/customers/ImportPreviewTable.tsx @@ -0,0 +1,56 @@ +import type { CustomerImportPreviewRow } from "@/types/customer"; + +const actionLabels = { + create: "Create", + update: "Update", + skip: "Skip", + error: "Error", +}; + +const actionClasses = { + create: "bg-emerald-50 text-emerald-700", + update: "bg-blue-50 text-blue-700", + skip: "bg-slate-100 text-slate-600", + error: "bg-red-50 text-red-700", +}; + +function issueText(row: CustomerImportPreviewRow) { + const issues = [...row.errors, ...row.warnings]; + if (issues.length === 0) { + return "-"; + } + return issues.map((issue) => `${issue.field || "Zeile"}: ${issue.message}`).join(" · "); +} + +export default function ImportPreviewTable({ rows }: { rows: CustomerImportPreviewRow[] }) { + return ( +
+ + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + ))} + +
ZeileAktionKundennummerFirmaHinweise
{row.row} + + {actionLabels[row.action]} + + {row.customer_number || "-"}{row.company_name || "-"}{issueText(row)}
+
+ ); +} diff --git a/frontend/athena/components/customers/ImportSummaryCard.tsx b/frontend/athena/components/customers/ImportSummaryCard.tsx new file mode 100644 index 0000000..0bc202d --- /dev/null +++ b/frontend/athena/components/customers/ImportSummaryCard.tsx @@ -0,0 +1,24 @@ +import type { CustomerImportSummary } from "@/types/customer"; + +const summaryItems = [ + ["Gesamt", "total_rows"], + ["Gültig", "valid_rows"], + ["Fehler", "error_count"], + ["Warnungen", "warning_count"], + ["Creates", "create_count"], + ["Updates", "update_count"], + ["Skips", "skip_count"], +] as const; + +export default function ImportSummaryCard({ summary }: { summary: CustomerImportSummary }) { + return ( +
+ {summaryItems.map(([label, key]) => ( +
+

{label}

+

{summary[key]}

+
+ ))} +
+ ); +} diff --git a/frontend/athena/lib/server/hermes-proxy.ts b/frontend/athena/lib/server/hermes-proxy.ts index 7bbc303..68452e4 100644 --- a/frontend/athena/lib/server/hermes-proxy.ts +++ b/frontend/athena/lib/server/hermes-proxy.ts @@ -27,6 +27,8 @@ export async function proxyHermesRequest( } let hermesResponse: Response; + const isMultipart = request.headers.get("content-type")?.includes("multipart/form-data") ?? false; + const hasBody = request.method !== "GET" && request.method !== "DELETE"; try { hermesResponse = await fetch(`${hermesUrl}${path}`, { @@ -34,11 +36,13 @@ export async function proxyHermesRequest( headers: { Authorization: `Bearer ${token}`, Accept: "application/json", - "Content-Type": "application/json", + ...(!isMultipart ? { "Content-Type": "application/json" } : {}), }, - body: request.method === "GET" || request.method === "DELETE" + body: !hasBody ? undefined - : await request.text(), + : isMultipart + ? await request.formData() + : await request.text(), cache: "no-store", }); } catch { diff --git a/frontend/athena/types/customer.ts b/frontend/athena/types/customer.ts index df840a8..2e0d6e8 100644 --- a/frontend/athena/types/customer.ts +++ b/frontend/athena/types/customer.ts @@ -1,6 +1,8 @@ export type CustomerStatus = "lead" | "active" | "inactive" | "blocked" | "archived"; export type CustomerType = "company" | "private" | "public_sector" | "partner" | "supplier"; export type AddressType = "billing" | "shipping" | "primary" | "other"; +export type CustomerImportMode = "create_only" | "update_existing" | "upsert"; +export type CustomerImportAction = "create" | "update" | "skip" | "error"; export interface CustomerAddress { id: number; @@ -87,3 +89,46 @@ export type CustomerContactPayload = { is_primary: boolean; notes: string; }; + +export interface CustomerImportIssue { + row: number; + field: string; + message: string; +} + +export interface CustomerImportPreviewRow { + row: number; + customer_number: string; + company_name: string; + action: CustomerImportAction; + errors: CustomerImportIssue[]; + warnings: CustomerImportIssue[]; +} + +export interface CustomerImportSummary { + total_rows: number; + valid_rows: number; + error_count: number; + warning_count: number; + duplicate_count: number; + create_count: number; + update_count: number; + skip_count: number; +} + +export interface CustomerImportPreviewResponse { + mode: CustomerImportMode; + summary: CustomerImportSummary; + rows: CustomerImportPreviewRow[]; + errors: CustomerImportIssue[]; + warnings: CustomerImportIssue[]; + duplicates: CustomerImportIssue[]; +} + +export interface CustomerImportCommitResponse { + mode: CustomerImportMode; + summary: CustomerImportSummary; + created: number; + updated: number; + skipped: number; +} diff --git a/frontend/athena/types/rbac.ts b/frontend/athena/types/rbac.ts index 1336f29..e3e3520 100644 --- a/frontend/athena/types/rbac.ts +++ b/frontend/athena/types/rbac.ts @@ -30,6 +30,8 @@ export interface RolePayload { export interface CurrentUser { id: number; + first_name: string; + last_name: string; username: string; email: string; role: string; From 228da8f81488115d96645fa2bfcc315dbb443b2a Mon Sep 17 00:00:00 2001 From: DS | Schubert Date: Fri, 3 Jul 2026 10:25:52 +0200 Subject: [PATCH 02/22] feat(knowledge): add service library --- .env.example | 2 + ARCHITECTURE.md | 69 ++++ CONTRIBUTING.md | 25 ++ ROADMAP.md | 27 +- backend/hermes/.env.example | 2 + .../versions/b6f3d8c1a920_create_knowledge.py | 219 ++++++++++++ backend/hermes/app/api/knowledge.py | 336 ++++++++++++++++++ backend/hermes/app/core/config.py | 2 + backend/hermes/app/db/database.py | 1 + backend/hermes/app/main.py | 2 + backend/hermes/app/models/knowledge.py | 101 ++++++ backend/hermes/app/rbac/defaults.py | 15 + .../app/repositories/knowledge_repository.py | 177 +++++++++ backend/hermes/app/schemas/knowledge.py | 200 +++++++++++ backend/hermes/app/services/audit_service.py | 13 + .../hermes/app/services/knowledge_service.py | 247 +++++++++++++ backend/hermes/docker-compose.yml | 8 + docker-compose.yml | 8 + .../app/api/knowledge/[...path]/route.ts | 51 +++ .../app/knowledge/devices/[id]/page.tsx | 85 +++++ .../athena/app/knowledge/devices/page.tsx | 149 ++++++++ .../app/knowledge/documents/[id]/page.tsx | 61 ++++ .../athena/app/knowledge/documents/page.tsx | 184 ++++++++++ .../app/knowledge/manufacturers/page.tsx | 132 +++++++ frontend/athena/app/knowledge/notes/page.tsx | 152 ++++++++ frontend/athena/app/knowledge/page.tsx | 121 +++++++ frontend/athena/components/Sidebar.tsx | 7 + .../knowledge/DocumentTypeBadge.tsx | 25 ++ .../components/knowledge/KnowledgeForms.tsx | 325 +++++++++++++++++ .../athena/components/knowledge/TagList.tsx | 15 + frontend/athena/lib/server/hermes-proxy.ts | 44 +++ frontend/athena/types/knowledge.ts | 79 ++++ 32 files changed, 2878 insertions(+), 6 deletions(-) create mode 100644 backend/hermes/alembic/versions/b6f3d8c1a920_create_knowledge.py create mode 100644 backend/hermes/app/api/knowledge.py create mode 100644 backend/hermes/app/models/knowledge.py create mode 100644 backend/hermes/app/repositories/knowledge_repository.py create mode 100644 backend/hermes/app/schemas/knowledge.py create mode 100644 backend/hermes/app/services/knowledge_service.py create mode 100644 frontend/athena/app/api/knowledge/[...path]/route.ts create mode 100644 frontend/athena/app/knowledge/devices/[id]/page.tsx create mode 100644 frontend/athena/app/knowledge/devices/page.tsx create mode 100644 frontend/athena/app/knowledge/documents/[id]/page.tsx create mode 100644 frontend/athena/app/knowledge/documents/page.tsx create mode 100644 frontend/athena/app/knowledge/manufacturers/page.tsx create mode 100644 frontend/athena/app/knowledge/notes/page.tsx create mode 100644 frontend/athena/app/knowledge/page.tsx create mode 100644 frontend/athena/components/knowledge/DocumentTypeBadge.tsx create mode 100644 frontend/athena/components/knowledge/KnowledgeForms.tsx create mode 100644 frontend/athena/components/knowledge/TagList.tsx create mode 100644 frontend/athena/types/knowledge.ts diff --git a/.env.example b/.env.example index 3cf62ea..cd7b089 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,5 @@ INITIAL_ADMIN_EMAIL= INITIAL_ADMIN_PASSWORD= INITIAL_ADMIN_FIRST_NAME= INITIAL_ADMIN_LAST_NAME= +KNOWLEDGE_STORAGE_PATH=/data/knowledge +KNOWLEDGE_MAX_UPLOAD_MB=50 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1cb2ecc..57ddb36 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -511,6 +511,69 @@ Athena nutzt einen globalen Toast-Provider fuer konsistente Erfolgs- und Fehlerm Aktuell werden Toasts in den CRUD-Flows fuer Benutzer, Rollen, Kunden und Ansprechpartner genutzt. Inline-Fehler bleiben dort erhalten, wo sie fuer Formulare und Dialoge hilfreich sind. +## Wissensdatenbank / Service Library + +v0.6.0 fuehrt das Modul Wissensdatenbank ein. Es verwaltet Hersteller, Geraete und Modelle, Dokumente sowie Reparaturhinweise fuer Funktechnik, Elektronikreparatur und Werkstattbetrieb. + +Hermes stellt dafuer Tabellen mit dem Prefix `knowledge_` bereit: + +- `knowledge_manufacturers` +- `knowledge_devices` +- `knowledge_documents` +- `knowledge_notes` +- `knowledge_categories` + +Athena stellt die BFF-Routen unter `/api/knowledge/...` bereit. Der Browser spricht weiterhin ausschliesslich mit Athena. + +### Knowledge-Dateispeicherung + +Dokumentuploads werden in v0.6.0 lokal im Hermes-Container oder in einem gemounteten Volume gespeichert. + +Konfiguration: + +- `KNOWLEDGE_STORAGE_PATH`, Default `/data/knowledge` +- `KNOWLEDGE_MAX_UPLOAD_MB`, Default `50` + +Docker Compose bindet das persistente Volume `knowledge-data` nach `/data/knowledge` ein. Dieses Volume darf nicht geloescht werden, wenn lokale Knowledge-Dateien erhalten bleiben sollen. + +Erlaubte Uploadtypen: + +- PDF +- JPG/JPEG +- PNG +- WEBP +- TXT +- ZIP + +Hermes normalisiert Dateinamen, validiert Extension und MIME-Type, begrenzt die Uploadgroesse und erzwingt, dass gespeicherte und heruntergeladene Dateien innerhalb von `KNOWLEDGE_STORAGE_PATH` liegen. + +### Knowledge-RBAC + +Neue Berechtigungen: + +- `knowledge.read` +- `knowledge.create` +- `knowledge.update` +- `knowledge.delete` +- `knowledge.upload` +- `knowledge.download` + +Standardrollen: + +- `administrator`: alle Knowledge-Rechte +- `management`: `knowledge.read`, `knowledge.download` +- `technician`: `knowledge.read`, `knowledge.create`, `knowledge.update`, `knowledge.upload`, `knowledge.download` +- `support`: `knowledge.read`, `knowledge.download` +- `guest`: keine Knowledge-Rechte + +Hermes erzwingt diese Rechte serverseitig. Athena nutzt sie nur fuer Navigation und UI. + +### Paperless-Vorbereitung + +Eine echte Paperless-ngx-Integration ist in v0.6.0 nicht aktiv. Dokumente enthalten bereits `paperless_document_id` und `external_url`, damit spaeter ein PaperlessConnector angebunden werden kann. API-Keys oder verpflichtende Paperless-Umgebungsvariablen werden in v0.6.0 nicht eingefuehrt. + +Spaetere Verknuepfungen sind fuer Kunden, Projekte, Tickets und Reparaturen vorgesehen. + ## Verzeichnisstruktur ```text @@ -564,6 +627,12 @@ docker-compose.yml `LOG_LEVEL` : Runtime-Loglevel fuer Hermes, z. B. `INFO`, `WARNING` oder `ERROR`. +`KNOWLEDGE_STORAGE_PATH` +: Lokales Speicherverzeichnis fuer Knowledge-Dateien. Default `/data/knowledge`. + +`KNOWLEDGE_MAX_UPLOAD_MB` +: Maximale Uploadgroesse fuer Knowledge-Dokumente in MB. Default `50`. + ### Athena `HERMES_INTERNAL_URL` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d64d2c8..9419823 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -220,10 +220,35 @@ Importmodi: Die Excel-freundliche Vorlage liegt unter `GET /api/customers/import/template`. +### Wissensdatenbank + +Das Knowledge-Modul folgt dem bestehenden Modulstandard: + +- SQLAlchemy-Modell in `backend/hermes/app/models/knowledge.py` +- Pydantic-Schemas in `backend/hermes/app/schemas/knowledge.py` +- Repository in `backend/hermes/app/repositories/knowledge_repository.py` +- Service-Logik in `backend/hermes/app/services/knowledge_service.py` +- FastAPI-Router in `backend/hermes/app/api/knowledge.py` +- Athena-BFF unter `/api/knowledge/...` +- Frontend-Seiten unter `/knowledge` + +Upload-Regeln: + +- Browser sendet Dateien nur an Athena. +- Athena leitet FormData serverseitig an Hermes weiter. +- Hermes validiert Dateityp, MIME-Type, Dateigroesse und Speicherpfad. +- Lokale Dateien liegen unter `KNOWLEDGE_STORAGE_PATH`. +- Audit Logs duerfen keine Datei-Inhalte enthalten. +- Keine Pfade aus unvalidierten Benutzereingaben zusammensetzen. + +Paperless-ngx ist nur vorbereitet. `paperless_document_id` und `external_url` duerfen gepflegt werden, aber es werden keine Paperless-Secrets oder API-Keys eingefuehrt. + ### Deployment Neue Installationen koennen optional ueber `INITIAL_ADMIN_*` einen initialen Administrator anlegen. Diese Variablen werden nur von Hermes gelesen und duerfen nicht im Frontend oder in Logs erscheinen. +Knowledge-Dateien benoetigen ein persistentes Docker-Volume. Vor produktiven Deployments muessen `KNOWLEDGE_STORAGE_PATH` und `KNOWLEDGE_MAX_UPLOAD_MB` bewusst gesetzt oder die Defaults akzeptiert werden. + ### Neue Permissions Neue Module muessen eigene stabile Permission-Strings erhalten. diff --git a/ROADMAP.md b/ROADMAP.md index f4c7770..8f5cddb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -44,7 +44,18 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - Excel-freundliche CSV-Vorlage ueber Athena - Audit Log fuer Kundenimporte ohne CSV-Rohdaten -## v0.6.0 - Projektmodul, geplant +## v0.6.0 - Wissensdatenbank / Service Library + +- Herstellerverwaltung +- Geraete- und Modellverwaltung +- Dokumentverwaltung mit lokalem Upload und Download +- Reparaturhinweise und Knowledge Notes +- Suche ueber Hersteller, Modelle, Dokumente und Notizen +- RBAC-Permissions fuer Knowledge-Lesen, Upload und Download +- Paperless-ngx vorbereitet ueber `paperless_document_id` und `external_url` +- Persistentes Docker-Volume fuer Knowledge-Dateien + +## v0.7.0 - Projektmodul, geplant - Projektstammdaten - Projektstatus und Verantwortliche @@ -52,7 +63,7 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - RBAC-Permissions fuer Projekte - Audit Logs fuer Projektaktionen -## v0.7.0 - Tickets, geplant +## v0.8.0 - Tickets, geplant - Ticketverwaltung - Status- und Prioritaetsmodell @@ -60,15 +71,19 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi - RBAC-Permissions fuer Tickets - Audit Logs fuer Ticketaktionen -## v0.8.0 - Kalender und Aufgaben, geplant +## v0.9.0 - Integrationen Paperless/Lexoffice, geplant + +- Paperless-ngx Connector fuer Wissensdokumente +- Lexoffice-Vorbereitung fuer Kunden- und Projektdaten +- Sichere Connector-Konfiguration ohne Browser-Secrets +- Verknuepfung externer Dokumente mit Kunden, Projekten, Tickets und Reparaturen + +## v1.0.0 - Kalender, Aufgaben und produktiver Beta-Release - Aufgabenverwaltung - Kalenderansicht - Faelligkeiten und Verantwortlichkeiten - Verknuepfung mit Kunden, Projekten und Tickets - -## v1.0.0 - Produktiver Beta-Release - - Gehaertete Produktionskonfiguration - Vollstaendige Migrationsstrategie - Deployment-Dokumentation diff --git a/backend/hermes/.env.example b/backend/hermes/.env.example index 865387d..fab2c0f 100644 --- a/backend/hermes/.env.example +++ b/backend/hermes/.env.example @@ -13,3 +13,5 @@ INITIAL_ADMIN_EMAIL= INITIAL_ADMIN_PASSWORD= INITIAL_ADMIN_FIRST_NAME= INITIAL_ADMIN_LAST_NAME= +KNOWLEDGE_STORAGE_PATH=/data/knowledge +KNOWLEDGE_MAX_UPLOAD_MB=50 diff --git a/backend/hermes/alembic/versions/b6f3d8c1a920_create_knowledge.py b/backend/hermes/alembic/versions/b6f3d8c1a920_create_knowledge.py new file mode 100644 index 0000000..0d7d537 --- /dev/null +++ b/backend/hermes/alembic/versions/b6f3d8c1a920_create_knowledge.py @@ -0,0 +1,219 @@ +"""create knowledge module + +Revision ID: b6f3d8c1a920 +Revises: a5e4b7c9d012 +Create Date: 2026-07-03 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "b6f3d8c1a920" +down_revision: Union[str, Sequence[str], None] = "a5e4b7c9d012" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +KNOWLEDGE_PERMISSIONS = [ + ("knowledge.read", "Wissen lesen", "Wissensdatenbank anzeigen", "knowledge"), + ("knowledge.create", "Wissen erstellen", "Wissensdatenbank-Einträge erstellen", "knowledge"), + ("knowledge.update", "Wissen bearbeiten", "Wissensdatenbank-Einträge aktualisieren", "knowledge"), + ("knowledge.delete", "Wissen löschen", "Wissensdatenbank-Einträge entfernen", "knowledge"), + ("knowledge.upload", "Wissen hochladen", "Wissensdokumente hochladen", "knowledge"), + ("knowledge.download", "Wissen herunterladen", "Wissensdokumente herunterladen", "knowledge"), +] + + +ROLE_PERMISSIONS = { + "administrator": [permission[0] for permission in KNOWLEDGE_PERMISSIONS], + "management": ["knowledge.read", "knowledge.download"], + "technician": ["knowledge.read", "knowledge.create", "knowledge.update", "knowledge.upload", "knowledge.download"], + "support": ["knowledge.read", "knowledge.download"], +} + + +def insert_permission(name: str, display_name: str, description: str, module: str) -> None: + op.execute( + sa.text( + """ + INSERT INTO permissions (name, display_name, description, module) + SELECT :name, :display_name, :description, :module + WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE name = :name) + """ + ).bindparams(name=name, display_name=display_name, description=description, module=module) + ) + + +def grant_permission(role_name: str, permission_name: str) -> None: + op.execute( + sa.text( + """ + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles + JOIN permissions ON permissions.name = :permission_name + WHERE roles.name = :role_name + AND NOT EXISTS ( + SELECT 1 FROM role_permissions + WHERE role_permissions.role_id = roles.id + AND role_permissions.permission_id = permissions.id + ) + """ + ).bindparams(role_name=role_name, permission_name=permission_name) + ) + + +def upgrade() -> None: + op.create_table( + "knowledge_manufacturers", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=160), nullable=False), + sa.Column("slug", sa.String(length=180), nullable=False), + sa.Column("website", sa.String(length=255), server_default="", nullable=False), + sa.Column("notes", sa.Text(), server_default="", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_knowledge_manufacturers_name"), "knowledge_manufacturers", ["name"], unique=True) + op.create_index(op.f("ix_knowledge_manufacturers_slug"), "knowledge_manufacturers", ["slug"], unique=True) + + op.create_table( + "knowledge_categories", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("slug", sa.String(length=140), nullable=False), + sa.Column("description", sa.Text(), server_default="", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_knowledge_categories_name"), "knowledge_categories", ["name"], unique=True) + op.create_index(op.f("ix_knowledge_categories_slug"), "knowledge_categories", ["slug"], unique=True) + + op.create_table( + "knowledge_devices", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("manufacturer_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=180), nullable=False), + sa.Column("slug", sa.String(length=200), nullable=False), + sa.Column("model_number", sa.String(length=120), server_default="", nullable=False), + sa.Column("device_type", sa.String(length=80), server_default="", nullable=False), + sa.Column("frequency_range", sa.String(length=120), server_default="", nullable=False), + sa.Column("production_year_from", sa.Integer(), nullable=True), + sa.Column("production_year_to", sa.Integer(), nullable=True), + sa.Column("notes", sa.Text(), server_default="", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["manufacturer_id"], ["knowledge_manufacturers.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("manufacturer_id", "slug", name="uq_knowledge_devices_manufacturer_slug"), + ) + op.create_index(op.f("ix_knowledge_devices_manufacturer_id"), "knowledge_devices", ["manufacturer_id"], unique=False) + op.create_index(op.f("ix_knowledge_devices_name"), "knowledge_devices", ["name"], unique=False) + op.create_index(op.f("ix_knowledge_devices_slug"), "knowledge_devices", ["slug"], unique=False) + op.create_index(op.f("ix_knowledge_devices_model_number"), "knowledge_devices", ["model_number"], unique=False) + op.create_index(op.f("ix_knowledge_devices_device_type"), "knowledge_devices", ["device_type"], unique=False) + + op.create_table( + "knowledge_documents", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("manufacturer_id", sa.Integer(), nullable=False), + sa.Column("device_id", sa.Integer(), nullable=True), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("slug", sa.String(length=280), nullable=False), + sa.Column("document_type", sa.String(length=80), nullable=False), + sa.Column("language", sa.String(length=20), server_default="de", nullable=False), + sa.Column("file_name", sa.String(length=255), server_default="", nullable=False), + sa.Column("file_path", sa.String(length=500), server_default="", nullable=False), + sa.Column("mime_type", sa.String(length=120), server_default="", nullable=False), + sa.Column("file_size", sa.Integer(), server_default="0", nullable=False), + sa.Column("checksum_sha256", sa.String(length=64), server_default="", nullable=False), + sa.Column("external_url", sa.String(length=500), server_default="", nullable=False), + sa.Column("paperless_document_id", sa.String(length=120), server_default="", nullable=False), + sa.Column("description", sa.Text(), server_default="", nullable=False), + sa.Column("tags", sa.JSON(), server_default="[]", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["device_id"], ["knowledge_devices.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["manufacturer_id"], ["knowledge_manufacturers.id"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_knowledge_documents_manufacturer_id"), "knowledge_documents", ["manufacturer_id"], unique=False) + op.create_index(op.f("ix_knowledge_documents_device_id"), "knowledge_documents", ["device_id"], unique=False) + op.create_index(op.f("ix_knowledge_documents_title"), "knowledge_documents", ["title"], unique=False) + op.create_index(op.f("ix_knowledge_documents_slug"), "knowledge_documents", ["slug"], unique=True) + op.create_index(op.f("ix_knowledge_documents_document_type"), "knowledge_documents", ["document_type"], unique=False) + op.create_index(op.f("ix_knowledge_documents_language"), "knowledge_documents", ["language"], unique=False) + op.create_index(op.f("ix_knowledge_documents_checksum_sha256"), "knowledge_documents", ["checksum_sha256"], unique=False) + op.create_index(op.f("ix_knowledge_documents_paperless_document_id"), "knowledge_documents", ["paperless_document_id"], unique=False) + + op.create_table( + "knowledge_notes", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("manufacturer_id", sa.Integer(), nullable=True), + sa.Column("device_id", sa.Integer(), nullable=True), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("note_type", sa.String(length=80), nullable=False), + sa.Column("severity", sa.String(length=40), server_default="", nullable=False), + sa.Column("tags", sa.JSON(), server_default="[]", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["device_id"], ["knowledge_devices.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["manufacturer_id"], ["knowledge_manufacturers.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_knowledge_notes_manufacturer_id"), "knowledge_notes", ["manufacturer_id"], unique=False) + op.create_index(op.f("ix_knowledge_notes_device_id"), "knowledge_notes", ["device_id"], unique=False) + op.create_index(op.f("ix_knowledge_notes_title"), "knowledge_notes", ["title"], unique=False) + op.create_index(op.f("ix_knowledge_notes_note_type"), "knowledge_notes", ["note_type"], unique=False) + op.create_index(op.f("ix_knowledge_notes_severity"), "knowledge_notes", ["severity"], unique=False) + + for permission in KNOWLEDGE_PERMISSIONS: + insert_permission(*permission) + for role_name, permission_names in ROLE_PERMISSIONS.items(): + for permission_name in permission_names: + grant_permission(role_name, permission_name) + + +def downgrade() -> None: + op.execute( + """ + DELETE FROM role_permissions + USING permissions + WHERE role_permissions.permission_id = permissions.id + AND permissions.module = 'knowledge' + """ + ) + op.execute("DELETE FROM permissions WHERE module = 'knowledge'") + op.drop_index(op.f("ix_knowledge_notes_severity"), table_name="knowledge_notes") + op.drop_index(op.f("ix_knowledge_notes_note_type"), table_name="knowledge_notes") + op.drop_index(op.f("ix_knowledge_notes_title"), table_name="knowledge_notes") + op.drop_index(op.f("ix_knowledge_notes_device_id"), table_name="knowledge_notes") + op.drop_index(op.f("ix_knowledge_notes_manufacturer_id"), table_name="knowledge_notes") + op.drop_table("knowledge_notes") + op.drop_index(op.f("ix_knowledge_documents_paperless_document_id"), table_name="knowledge_documents") + op.drop_index(op.f("ix_knowledge_documents_checksum_sha256"), table_name="knowledge_documents") + op.drop_index(op.f("ix_knowledge_documents_language"), table_name="knowledge_documents") + op.drop_index(op.f("ix_knowledge_documents_document_type"), table_name="knowledge_documents") + op.drop_index(op.f("ix_knowledge_documents_slug"), table_name="knowledge_documents") + op.drop_index(op.f("ix_knowledge_documents_title"), table_name="knowledge_documents") + op.drop_index(op.f("ix_knowledge_documents_device_id"), table_name="knowledge_documents") + op.drop_index(op.f("ix_knowledge_documents_manufacturer_id"), table_name="knowledge_documents") + op.drop_table("knowledge_documents") + op.drop_index(op.f("ix_knowledge_devices_device_type"), table_name="knowledge_devices") + op.drop_index(op.f("ix_knowledge_devices_model_number"), table_name="knowledge_devices") + op.drop_index(op.f("ix_knowledge_devices_slug"), table_name="knowledge_devices") + op.drop_index(op.f("ix_knowledge_devices_name"), table_name="knowledge_devices") + op.drop_index(op.f("ix_knowledge_devices_manufacturer_id"), table_name="knowledge_devices") + op.drop_table("knowledge_devices") + op.drop_index(op.f("ix_knowledge_categories_slug"), table_name="knowledge_categories") + op.drop_index(op.f("ix_knowledge_categories_name"), table_name="knowledge_categories") + op.drop_table("knowledge_categories") + op.drop_index(op.f("ix_knowledge_manufacturers_slug"), table_name="knowledge_manufacturers") + op.drop_index(op.f("ix_knowledge_manufacturers_name"), table_name="knowledge_manufacturers") + op.drop_table("knowledge_manufacturers") diff --git a/backend/hermes/app/api/knowledge.py b/backend/hermes/app/api/knowledge.py new file mode 100644 index 0000000..1eebcc2 --- /dev/null +++ b/backend/hermes/app/api/knowledge.py @@ -0,0 +1,336 @@ +import logging +from pathlib import Path + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile, status +from fastapi.responses import FileResponse +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session +from starlette.requests import Request + +from app.core.rbac import require_permission +from app.db.database import get_db +from app.models.user import User +from app.repositories.knowledge_repository import KnowledgeRepository +from app.schemas.knowledge import ( + DocumentType, + KnowledgeDeviceCreate, + KnowledgeDeviceResponse, + KnowledgeDeviceUpdate, + KnowledgeDocumentCreate, + KnowledgeDocumentResponse, + KnowledgeDocumentUpdate, + KnowledgeManufacturerCreate, + KnowledgeManufacturerResponse, + KnowledgeManufacturerUpdate, + KnowledgeNoteCreate, + KnowledgeNoteResponse, + KnowledgeNoteUpdate, + KnowledgeSearchResult, + NoteType, + normalize_tags, +) +from app.services.audit_service import sanitize, write_audit_log +from app.services.knowledge_service import KnowledgeService, assert_safe_path + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/knowledge", tags=["Knowledge"]) + + +def get_manufacturer_or_404(db: Session, manufacturer_id: int): + manufacturer = KnowledgeRepository.get_manufacturer(db, manufacturer_id) + if manufacturer is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden") + return manufacturer + + +def get_device_or_404(db: Session, device_id: int): + device = KnowledgeRepository.get_device(db, device_id) + if device is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gerät nicht gefunden") + return device + + +def get_document_or_404(db: Session, document_id: int): + document = KnowledgeRepository.get_document(db, document_id) + if document is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden") + return document + + +def get_note_or_404(db: Session, note_id: int): + note = KnowledgeRepository.get_note(db, note_id) + if note is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Notiz nicht gefunden") + return note + + +def delete_or_conflict(db: Session, entity) -> None: + try: + db.delete(entity) + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Eintrag wird noch verwendet") from exc + + +@router.get("/manufacturers", response_model=list[KnowledgeManufacturerResponse]) +def list_manufacturers( + q: str = "", + limit: int = Query(50, ge=1, le=100), + offset: int = Query(0, ge=0), + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("knowledge.read")), +): + logger.info("knowledge.manufacturers.list", extra={"actor_user_id": current_user.id}) + return KnowledgeRepository.list_manufacturers(db, q=q, limit=limit, offset=offset) + + +@router.get("/manufacturers/{manufacturer_id}", response_model=KnowledgeManufacturerResponse) +def get_manufacturer(manufacturer_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))): + logger.info("knowledge.manufacturers.detail", extra={"actor_user_id": current_user.id, "target_manufacturer_id": manufacturer_id}) + return get_manufacturer_or_404(db, manufacturer_id) + + +@router.post("/manufacturers", response_model=KnowledgeManufacturerResponse, status_code=status.HTTP_201_CREATED) +def create_manufacturer( + payload: KnowledgeManufacturerCreate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("knowledge.create")), +): + manufacturer = KnowledgeService.create_manufacturer(db, payload) + write_audit_log(db, action="knowledge.manufacturers.create", entity_type="knowledge_manufacturers", entity_id=manufacturer.id, entity_label=manufacturer.name, actor=current_user, request=request, after_data=manufacturer) + return manufacturer + + +@router.put("/manufacturers/{manufacturer_id}", response_model=KnowledgeManufacturerResponse) +def update_manufacturer( + manufacturer_id: int, + payload: KnowledgeManufacturerUpdate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("knowledge.update")), +): + manufacturer = get_manufacturer_or_404(db, manufacturer_id) + before_data = sanitize(manufacturer) + updated = KnowledgeService.update_manufacturer(db, manufacturer, payload) + write_audit_log(db, action="knowledge.manufacturers.update", entity_type="knowledge_manufacturers", entity_id=updated.id, entity_label=updated.name, actor=current_user, request=request, before_data=before_data, after_data=updated) + return updated + + +@router.delete("/manufacturers/{manufacturer_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_manufacturer( + manufacturer_id: int, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("knowledge.delete")), +): + manufacturer = get_manufacturer_or_404(db, manufacturer_id) + before_data = sanitize(manufacturer) + label = manufacturer.name + delete_or_conflict(db, manufacturer) + write_audit_log(db, action="knowledge.manufacturers.delete", entity_type="knowledge_manufacturers", entity_id=manufacturer_id, entity_label=label, actor=current_user, request=request, before_data=before_data) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/devices", response_model=list[KnowledgeDeviceResponse]) +def list_devices( + q: str = "", + manufacturer_id: int | None = None, + device_type: str = "", + limit: int = Query(50, ge=1, le=100), + offset: int = Query(0, ge=0), + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("knowledge.read")), +): + logger.info("knowledge.devices.list", extra={"actor_user_id": current_user.id}) + return KnowledgeRepository.list_devices(db, q=q, manufacturer_id=manufacturer_id, device_type=device_type, limit=limit, offset=offset) + + +@router.get("/devices/{device_id}", response_model=KnowledgeDeviceResponse) +def get_device(device_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))): + logger.info("knowledge.devices.detail", extra={"actor_user_id": current_user.id, "target_device_id": device_id}) + return get_device_or_404(db, device_id) + + +@router.post("/devices", response_model=KnowledgeDeviceResponse, status_code=status.HTTP_201_CREATED) +def create_device(payload: KnowledgeDeviceCreate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.create"))): + device = KnowledgeService.create_device(db, payload) + write_audit_log(db, action="knowledge.devices.create", entity_type="knowledge_devices", entity_id=device.id, entity_label=device.name, actor=current_user, request=request, after_data=device) + return device + + +@router.put("/devices/{device_id}", response_model=KnowledgeDeviceResponse) +def update_device(device_id: int, payload: KnowledgeDeviceUpdate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.update"))): + device = get_device_or_404(db, device_id) + before_data = sanitize(device) + updated = KnowledgeService.update_device(db, device, payload) + write_audit_log(db, action="knowledge.devices.update", entity_type="knowledge_devices", entity_id=updated.id, entity_label=updated.name, actor=current_user, request=request, before_data=before_data, after_data=updated) + return updated + + +@router.delete("/devices/{device_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_device(device_id: int, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.delete"))): + device = get_device_or_404(db, device_id) + before_data = sanitize(device) + label = device.name + delete_or_conflict(db, device) + write_audit_log(db, action="knowledge.devices.delete", entity_type="knowledge_devices", entity_id=device_id, entity_label=label, actor=current_user, request=request, before_data=before_data) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/documents", response_model=list[KnowledgeDocumentResponse]) +def list_documents( + q: str = "", + manufacturer_id: int | None = None, + device_id: int | None = None, + document_type: str = "", + language: str = "", + tag: str = "", + limit: int = Query(50, ge=1, le=100), + offset: int = Query(0, ge=0), + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("knowledge.read")), +): + logger.info("knowledge.documents.list", extra={"actor_user_id": current_user.id}) + return KnowledgeRepository.list_documents(db, q=q, manufacturer_id=manufacturer_id, device_id=device_id, document_type=document_type, language=language, tag=tag, limit=limit, offset=offset) + + +@router.get("/documents/{document_id:int}", response_model=KnowledgeDocumentResponse) +def get_document(document_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))): + logger.info("knowledge.documents.detail", extra={"actor_user_id": current_user.id, "target_document_id": document_id}) + return get_document_or_404(db, document_id) + + +@router.post("/documents", response_model=KnowledgeDocumentResponse, status_code=status.HTTP_201_CREATED) +def create_document(payload: KnowledgeDocumentCreate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.create"))): + document = KnowledgeService.create_document(db, payload) + write_audit_log(db, action="knowledge.documents.create", entity_type="knowledge_documents", entity_id=document.id, entity_label=document.title, actor=current_user, request=request, after_data=document) + return document + + +@router.post("/documents/upload", response_model=KnowledgeDocumentResponse, status_code=status.HTTP_201_CREATED) +async def upload_document( + request: Request, + manufacturer_id: int = Form(...), + title: str = Form(...), + document_type: DocumentType = Form(...), + file: UploadFile = File(...), + device_id: int | None = Form(default=None), + language: str = Form(default="de"), + external_url: str = Form(default=""), + paperless_document_id: str = Form(default=""), + description: str = Form(default=""), + tags: str = Form(default=""), + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("knowledge.upload")), +): + payload = KnowledgeDocumentCreate( + manufacturer_id=manufacturer_id, + device_id=device_id, + title=title, + document_type=document_type, + language=language, + external_url=external_url or None, + paperless_document_id=paperless_document_id, + description=description, + tags=normalize_tags(tags), + ) + document = await KnowledgeService.upload_document(db, file=file, payload=payload) + write_audit_log(db, action="knowledge.documents.upload", entity_type="knowledge_documents", entity_id=document.id, entity_label=document.title, actor=current_user, request=request, after_data={key: value for key, value in sanitize(document).items() if key != "file_path"}) + return document + + +@router.put("/documents/{document_id:int}", response_model=KnowledgeDocumentResponse) +def update_document(document_id: int, payload: KnowledgeDocumentUpdate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.update"))): + document = get_document_or_404(db, document_id) + before_data = sanitize(document) + updated = KnowledgeService.update_document(db, document, payload) + write_audit_log(db, action="knowledge.documents.update", entity_type="knowledge_documents", entity_id=updated.id, entity_label=updated.title, actor=current_user, request=request, before_data=before_data, after_data=updated) + return updated + + +@router.delete("/documents/{document_id:int}", status_code=status.HTTP_204_NO_CONTENT) +def delete_document(document_id: int, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.delete"))): + document = get_document_or_404(db, document_id) + before_data = sanitize(document) + label = document.title + delete_or_conflict(db, document) + write_audit_log(db, action="knowledge.documents.delete", entity_type="knowledge_documents", entity_id=document_id, entity_label=label, actor=current_user, request=request, before_data=before_data) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/documents/{document_id:int}/download") +def download_document(document_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.download"))): + document = get_document_or_404(db, document_id) + if not document.file_path: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument hat keine lokale Datei") + path = assert_safe_path(Path(document.file_path)) + if not path.exists() or not path.is_file(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht gefunden") + logger.info("knowledge.documents.download", extra={"actor_user_id": current_user.id, "target_document_id": document_id}) + return FileResponse(path, media_type=document.mime_type or "application/octet-stream", filename=document.file_name) + + +@router.get("/notes", response_model=list[KnowledgeNoteResponse]) +def list_notes( + q: str = "", + manufacturer_id: int | None = None, + device_id: int | None = None, + note_type: str = "", + tag: str = "", + limit: int = Query(50, ge=1, le=100), + offset: int = Query(0, ge=0), + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("knowledge.read")), +): + logger.info("knowledge.notes.list", extra={"actor_user_id": current_user.id}) + return KnowledgeRepository.list_notes(db, q=q, manufacturer_id=manufacturer_id, device_id=device_id, note_type=note_type, tag=tag, limit=limit, offset=offset) + + +@router.get("/notes/{note_id}", response_model=KnowledgeNoteResponse) +def get_note(note_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))): + logger.info("knowledge.notes.detail", extra={"actor_user_id": current_user.id, "target_note_id": note_id}) + return get_note_or_404(db, note_id) + + +@router.post("/notes", response_model=KnowledgeNoteResponse, status_code=status.HTTP_201_CREATED) +def create_note(payload: KnowledgeNoteCreate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.create"))): + note = KnowledgeService.create_note(db, payload) + write_audit_log(db, action="knowledge.notes.create", entity_type="knowledge_notes", entity_id=note.id, entity_label=note.title, actor=current_user, request=request, after_data=note) + return note + + +@router.put("/notes/{note_id}", response_model=KnowledgeNoteResponse) +def update_note(note_id: int, payload: KnowledgeNoteUpdate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.update"))): + note = get_note_or_404(db, note_id) + before_data = sanitize(note) + updated = KnowledgeService.update_note(db, note, payload) + write_audit_log(db, action="knowledge.notes.update", entity_type="knowledge_notes", entity_id=updated.id, entity_label=updated.title, actor=current_user, request=request, before_data=before_data, after_data=updated) + return updated + + +@router.delete("/notes/{note_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_note(note_id: int, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.delete"))): + note = get_note_or_404(db, note_id) + before_data = sanitize(note) + label = note.title + delete_or_conflict(db, note) + write_audit_log(db, action="knowledge.notes.delete", entity_type="knowledge_notes", entity_id=note_id, entity_label=label, actor=current_user, request=request, before_data=before_data) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/search", response_model=list[KnowledgeSearchResult]) +def search_knowledge(q: str = Query(..., min_length=1), db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))): + logger.info("knowledge.search", extra={"actor_user_id": current_user.id}) + manufacturers = KnowledgeRepository.list_manufacturers(db, q=q, limit=10, offset=0) + devices = KnowledgeRepository.list_devices(db, q=q, limit=10, offset=0) + documents = KnowledgeRepository.list_documents(db, q=q, limit=10, offset=0) + notes = KnowledgeRepository.list_notes(db, q=q, limit=10, offset=0) + results: list[KnowledgeSearchResult] = [] + results.extend(KnowledgeSearchResult(entity_type="manufacturer", id=item.id, title=item.name, href=f"/knowledge/manufacturers?selected={item.id}") for item in manufacturers) + results.extend(KnowledgeSearchResult(entity_type="device", id=item.id, title=item.name, subtitle=item.model_number, href=f"/knowledge/devices/{item.id}") for item in devices) + results.extend(KnowledgeSearchResult(entity_type="document", id=item.id, title=item.title, subtitle=item.document_type, href=f"/knowledge/documents/{item.id}") for item in documents) + results.extend(KnowledgeSearchResult(entity_type="note", id=item.id, title=item.title, subtitle=item.note_type, href=f"/knowledge/notes?selected={item.id}") for item in notes) + return results[:30] diff --git a/backend/hermes/app/core/config.py b/backend/hermes/app/core/config.py index 92fd66e..f9fce54 100644 --- a/backend/hermes/app/core/config.py +++ b/backend/hermes/app/core/config.py @@ -15,6 +15,8 @@ class Settings(BaseSettings): initial_admin_password: str | None = None initial_admin_first_name: str = "" initial_admin_last_name: str = "" + knowledge_storage_path: str = "/data/knowledge" + knowledge_max_upload_mb: int = 50 model_config = SettingsConfigDict( env_file=".env", diff --git a/backend/hermes/app/db/database.py b/backend/hermes/app/db/database.py index 3537b5a..caf7949 100644 --- a/backend/hermes/app/db/database.py +++ b/backend/hermes/app/db/database.py @@ -22,6 +22,7 @@ class Base(DeclarativeBase): # <<< HIER IMPORTIEREN >>> import app.models.rbac import app.models.customer +import app.models.knowledge import app.models.audit import app.models.user diff --git a/backend/hermes/app/main.py b/backend/hermes/app/main.py index 4edba3d..2a79c46 100644 --- a/backend/hermes/app/main.py +++ b/backend/hermes/app/main.py @@ -13,6 +13,7 @@ from app.api.auth import router as auth_router from app.api.audit import router as audit_router from app.api.customers import router as customers_router from app.api.dashboard import router as dashboard_router +from app.api.knowledge import router as knowledge_router from app.api.permissions import router as permissions_router from app.api.roles import router as roles_router from app.api.users import router as users_router @@ -36,6 +37,7 @@ app.include_router(users_router) app.include_router(roles_router) app.include_router(permissions_router) app.include_router(customers_router) +app.include_router(knowledge_router) app.include_router(dashboard_router) logger = logging.getLogger(__name__) diff --git a/backend/hermes/app/models/knowledge.py b/backend/hermes/app/models/knowledge.py new file mode 100644 index 0000000..61bb1e5 --- /dev/null +++ b/backend/hermes/app/models/knowledge.py @@ -0,0 +1,101 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.database import Base + + +class KnowledgeManufacturer(Base): + __tablename__ = "knowledge_manufacturers" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(160), unique=True, index=True) + slug: Mapped[str] = mapped_column(String(180), unique=True, index=True) + website: Mapped[str] = mapped_column(String(255), default="", server_default="") + notes: Mapped[str] = mapped_column(Text, default="", server_default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + devices: Mapped[list["KnowledgeDevice"]] = relationship(back_populates="manufacturer", cascade="all, delete-orphan") + documents: Mapped[list["KnowledgeDocument"]] = relationship(back_populates="manufacturer") + notes_rel: Mapped[list["KnowledgeNote"]] = relationship(back_populates="manufacturer") + + +class KnowledgeDevice(Base): + __tablename__ = "knowledge_devices" + __table_args__ = ( + UniqueConstraint("manufacturer_id", "slug", name="uq_knowledge_devices_manufacturer_slug"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + manufacturer_id: Mapped[int] = mapped_column(ForeignKey("knowledge_manufacturers.id", ondelete="CASCADE"), index=True) + name: Mapped[str] = mapped_column(String(180), index=True) + slug: Mapped[str] = mapped_column(String(200), index=True) + model_number: Mapped[str] = mapped_column(String(120), default="", server_default="", index=True) + device_type: Mapped[str] = mapped_column(String(80), default="", server_default="", index=True) + frequency_range: Mapped[str] = mapped_column(String(120), default="", server_default="") + production_year_from: Mapped[int | None] = mapped_column(Integer, nullable=True) + production_year_to: Mapped[int | None] = mapped_column(Integer, nullable=True) + notes: Mapped[str] = mapped_column(Text, default="", server_default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + manufacturer: Mapped[KnowledgeManufacturer] = relationship(back_populates="devices") + documents: Mapped[list["KnowledgeDocument"]] = relationship(back_populates="device") + notes_rel: Mapped[list["KnowledgeNote"]] = relationship(back_populates="device") + + +class KnowledgeDocument(Base): + __tablename__ = "knowledge_documents" + + id: Mapped[int] = mapped_column(primary_key=True) + manufacturer_id: Mapped[int] = mapped_column(ForeignKey("knowledge_manufacturers.id", ondelete="RESTRICT"), index=True) + device_id: Mapped[int | None] = mapped_column(ForeignKey("knowledge_devices.id", ondelete="SET NULL"), nullable=True, index=True) + title: Mapped[str] = mapped_column(String(255), index=True) + slug: Mapped[str] = mapped_column(String(280), unique=True, index=True) + document_type: Mapped[str] = mapped_column(String(80), index=True) + language: Mapped[str] = mapped_column(String(20), default="de", server_default="", index=True) + file_name: Mapped[str] = mapped_column(String(255), default="", server_default="") + file_path: Mapped[str] = mapped_column(String(500), default="", server_default="") + mime_type: Mapped[str] = mapped_column(String(120), default="", server_default="") + file_size: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + checksum_sha256: Mapped[str] = mapped_column(String(64), default="", server_default="", index=True) + external_url: Mapped[str] = mapped_column(String(500), default="", server_default="") + paperless_document_id: Mapped[str] = mapped_column(String(120), default="", server_default="", index=True) + description: Mapped[str] = mapped_column(Text, default="", server_default="") + tags: Mapped[list[str]] = mapped_column(JSON, default=list, server_default="[]") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + manufacturer: Mapped[KnowledgeManufacturer] = relationship(back_populates="documents") + device: Mapped[KnowledgeDevice | None] = relationship(back_populates="documents") + + +class KnowledgeNote(Base): + __tablename__ = "knowledge_notes" + + id: Mapped[int] = mapped_column(primary_key=True) + manufacturer_id: Mapped[int | None] = mapped_column(ForeignKey("knowledge_manufacturers.id", ondelete="SET NULL"), nullable=True, index=True) + device_id: Mapped[int | None] = mapped_column(ForeignKey("knowledge_devices.id", ondelete="SET NULL"), nullable=True, index=True) + title: Mapped[str] = mapped_column(String(255), index=True) + content: Mapped[str] = mapped_column(Text) + note_type: Mapped[str] = mapped_column(String(80), index=True) + severity: Mapped[str] = mapped_column(String(40), default="", server_default="", index=True) + tags: Mapped[list[str]] = mapped_column(JSON, default=list, server_default="[]") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + manufacturer: Mapped[KnowledgeManufacturer | None] = relationship(back_populates="notes_rel") + device: Mapped[KnowledgeDevice | None] = relationship(back_populates="notes_rel") + + +class KnowledgeCategory(Base): + __tablename__ = "knowledge_categories" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(120), unique=True, index=True) + slug: Mapped[str] = mapped_column(String(140), unique=True, index=True) + description: Mapped[str] = mapped_column(Text, default="", server_default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/hermes/app/rbac/defaults.py b/backend/hermes/app/rbac/defaults.py index ff244bf..2a67941 100644 --- a/backend/hermes/app/rbac/defaults.py +++ b/backend/hermes/app/rbac/defaults.py @@ -70,6 +70,12 @@ STANDARD_PERMISSIONS = [ ("system.settings.read", "Einstellungen lesen", "Systemeinstellungen anzeigen", "system"), ("system.settings.update", "Einstellungen bearbeiten", "Systemeinstellungen aktualisieren", "system"), ("audit_logs.read", "Audit Logs lesen", "Audit Logs anzeigen", "audit_logs"), + ("knowledge.read", "Wissen lesen", "Wissensdatenbank anzeigen", "knowledge"), + ("knowledge.create", "Wissen erstellen", "Wissensdatenbank-Einträge erstellen", "knowledge"), + ("knowledge.update", "Wissen bearbeiten", "Wissensdatenbank-Einträge aktualisieren", "knowledge"), + ("knowledge.delete", "Wissen löschen", "Wissensdatenbank-Einträge entfernen", "knowledge"), + ("knowledge.upload", "Wissen hochladen", "Wissensdokumente hochladen", "knowledge"), + ("knowledge.download", "Wissen herunterladen", "Wissensdokumente herunterladen", "knowledge"), ] ROLE_PERMISSION_NAMES = { @@ -78,6 +84,8 @@ ROLE_PERMISSION_NAMES = { "dashboard.read", "users.read", "customers.read", + "knowledge.read", + "knowledge.download", "projects.read", "tickets.read", }, @@ -95,11 +103,18 @@ ROLE_PERMISSION_NAMES = { "tickets.read", "tickets.create", "tickets.update", + "knowledge.read", + "knowledge.create", + "knowledge.update", + "knowledge.upload", + "knowledge.download", }, "support": { "dashboard.read", "users.read", "customers.read", + "knowledge.read", + "knowledge.download", "tickets.read", "tickets.create", "tickets.update", diff --git a/backend/hermes/app/repositories/knowledge_repository.py b/backend/hermes/app/repositories/knowledge_repository.py new file mode 100644 index 0000000..4c81d39 --- /dev/null +++ b/backend/hermes/app/repositories/knowledge_repository.py @@ -0,0 +1,177 @@ +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session, joinedload + +from app.models.knowledge import ( + KnowledgeDevice, + KnowledgeDocument, + KnowledgeManufacturer, + KnowledgeNote, +) + + +def paginate(query, limit: int, offset: int): + return query.limit(max(1, min(limit, 100))).offset(max(0, offset)) + + +class KnowledgeRepository: + @staticmethod + def list_manufacturers(db: Session, *, q: str = "", limit: int = 50, offset: int = 0) -> list[KnowledgeManufacturer]: + query = select(KnowledgeManufacturer).order_by(KnowledgeManufacturer.name) + if q: + pattern = f"%{q.lower()}%" + query = query.where(func.lower(KnowledgeManufacturer.name).like(pattern)) + return list(db.scalars(paginate(query, limit, offset))) + + @staticmethod + def get_manufacturer(db: Session, manufacturer_id: int) -> KnowledgeManufacturer | None: + return db.get(KnowledgeManufacturer, manufacturer_id) + + @staticmethod + def get_manufacturer_by_name(db: Session, name: str, exclude_id: int | None = None) -> KnowledgeManufacturer | None: + query = select(KnowledgeManufacturer).where(func.lower(KnowledgeManufacturer.name) == name.lower()) + if exclude_id is not None: + query = query.where(KnowledgeManufacturer.id != exclude_id) + return db.scalar(query) + + @staticmethod + def get_manufacturer_by_slug(db: Session, slug: str, exclude_id: int | None = None) -> KnowledgeManufacturer | None: + query = select(KnowledgeManufacturer).where(KnowledgeManufacturer.slug == slug) + if exclude_id is not None: + query = query.where(KnowledgeManufacturer.id != exclude_id) + return db.scalar(query) + + @staticmethod + def list_devices( + db: Session, + *, + q: str = "", + manufacturer_id: int | None = None, + device_type: str = "", + limit: int = 50, + offset: int = 0, + ) -> list[KnowledgeDevice]: + query = select(KnowledgeDevice).options(joinedload(KnowledgeDevice.manufacturer)).order_by(KnowledgeDevice.name) + if manufacturer_id is not None: + query = query.where(KnowledgeDevice.manufacturer_id == manufacturer_id) + if device_type: + query = query.where(KnowledgeDevice.device_type == device_type) + if q: + pattern = f"%{q.lower()}%" + query = query.where( + or_( + func.lower(KnowledgeDevice.name).like(pattern), + func.lower(KnowledgeDevice.model_number).like(pattern), + ) + ) + return list(db.scalars(paginate(query, limit, offset))) + + @staticmethod + def get_device(db: Session, device_id: int) -> KnowledgeDevice | None: + return db.scalar( + select(KnowledgeDevice) + .where(KnowledgeDevice.id == device_id) + .options(joinedload(KnowledgeDevice.manufacturer)) + ) + + @staticmethod + def get_device_by_slug( + db: Session, + *, + manufacturer_id: int, + slug: str, + exclude_id: int | None = None, + ) -> KnowledgeDevice | None: + query = select(KnowledgeDevice).where(KnowledgeDevice.manufacturer_id == manufacturer_id, KnowledgeDevice.slug == slug) + if exclude_id is not None: + query = query.where(KnowledgeDevice.id != exclude_id) + return db.scalar(query) + + @staticmethod + def list_documents( + db: Session, + *, + q: str = "", + manufacturer_id: int | None = None, + device_id: int | None = None, + document_type: str = "", + language: str = "", + tag: str = "", + limit: int = 50, + offset: int = 0, + ) -> list[KnowledgeDocument]: + query = ( + select(KnowledgeDocument) + .options(joinedload(KnowledgeDocument.manufacturer), joinedload(KnowledgeDocument.device).joinedload(KnowledgeDevice.manufacturer)) + .order_by(KnowledgeDocument.created_at.desc()) + ) + if manufacturer_id is not None: + query = query.where(KnowledgeDocument.manufacturer_id == manufacturer_id) + if device_id is not None: + query = query.where(KnowledgeDocument.device_id == device_id) + if document_type: + query = query.where(KnowledgeDocument.document_type == document_type) + if language: + query = query.where(KnowledgeDocument.language == language) + if q: + pattern = f"%{q.lower()}%" + query = query.where(func.lower(KnowledgeDocument.title).like(pattern)) + documents = list(db.scalars(paginate(query, limit, offset))) + if tag: + tag_lower = tag.lower() + documents = [document for document in documents if any(item.lower() == tag_lower for item in document.tags)] + return documents + + @staticmethod + def get_document(db: Session, document_id: int) -> KnowledgeDocument | None: + return db.scalar( + select(KnowledgeDocument) + .where(KnowledgeDocument.id == document_id) + .options(joinedload(KnowledgeDocument.manufacturer), joinedload(KnowledgeDocument.device).joinedload(KnowledgeDevice.manufacturer)) + ) + + @staticmethod + def get_document_by_slug(db: Session, slug: str, exclude_id: int | None = None) -> KnowledgeDocument | None: + query = select(KnowledgeDocument).where(KnowledgeDocument.slug == slug) + if exclude_id is not None: + query = query.where(KnowledgeDocument.id != exclude_id) + return db.scalar(query) + + @staticmethod + def list_notes( + db: Session, + *, + q: str = "", + manufacturer_id: int | None = None, + device_id: int | None = None, + note_type: str = "", + tag: str = "", + limit: int = 50, + offset: int = 0, + ) -> list[KnowledgeNote]: + query = ( + select(KnowledgeNote) + .options(joinedload(KnowledgeNote.manufacturer), joinedload(KnowledgeNote.device).joinedload(KnowledgeDevice.manufacturer)) + .order_by(KnowledgeNote.created_at.desc()) + ) + if manufacturer_id is not None: + query = query.where(KnowledgeNote.manufacturer_id == manufacturer_id) + if device_id is not None: + query = query.where(KnowledgeNote.device_id == device_id) + if note_type: + query = query.where(KnowledgeNote.note_type == note_type) + if q: + pattern = f"%{q.lower()}%" + query = query.where(or_(func.lower(KnowledgeNote.title).like(pattern), func.lower(KnowledgeNote.content).like(pattern))) + notes = list(db.scalars(paginate(query, limit, offset))) + if tag: + tag_lower = tag.lower() + notes = [note for note in notes if any(item.lower() == tag_lower for item in note.tags)] + return notes + + @staticmethod + def get_note(db: Session, note_id: int) -> KnowledgeNote | None: + return db.scalar( + select(KnowledgeNote) + .where(KnowledgeNote.id == note_id) + .options(joinedload(KnowledgeNote.manufacturer), joinedload(KnowledgeNote.device).joinedload(KnowledgeDevice.manufacturer)) + ) diff --git a/backend/hermes/app/schemas/knowledge.py b/backend/hermes/app/schemas/knowledge.py new file mode 100644 index 0000000..ebef643 --- /dev/null +++ b/backend/hermes/app/schemas/knowledge.py @@ -0,0 +1,200 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator + +DocumentType = Literal[ + "manual", + "service_manual", + "schematic", + "alignment", + "parts_list", + "firmware", + "datasheet", + "service_bulletin", + "other", +] +NoteType = Literal["repair", "known_fault", "alignment", "spare_part", "general"] +NoteSeverity = Literal["low", "medium", "high", "critical", ""] + + +def normalize_text(value: object) -> str: + if value is None: + return "" + return str(value).strip() + + +def normalize_tags(value: object) -> list[str]: + if value is None or value == "": + return [] + if isinstance(value, str): + parts = value.split(",") + elif isinstance(value, list): + parts = value + else: + parts = [] + seen: set[str] = set() + tags: list[str] = [] + for part in parts: + tag = str(part).strip() + key = tag.lower() + if tag and key not in seen: + seen.add(key) + tags.append(tag) + return tags + + +class KnowledgeManufacturerBase(BaseModel): + name: str = Field(min_length=2, max_length=160) + website: HttpUrl | None = None + notes: str = "" + + @field_validator("name", "notes", mode="before") + @classmethod + def normalize_text_fields(cls, value: object) -> str: + return normalize_text(value) + + +class KnowledgeManufacturerCreate(KnowledgeManufacturerBase): + pass + + +class KnowledgeManufacturerUpdate(KnowledgeManufacturerBase): + pass + + +class KnowledgeManufacturerResponse(KnowledgeManufacturerBase): + id: int + slug: str + website: str + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class KnowledgeDeviceBase(BaseModel): + manufacturer_id: int + name: str = Field(min_length=2, max_length=180) + model_number: str = Field(default="", max_length=120) + device_type: str = Field(default="", max_length=80) + frequency_range: str = Field(default="", max_length=120) + production_year_from: int | None = Field(default=None, ge=1900, le=2100) + production_year_to: int | None = Field(default=None, ge=1900, le=2100) + notes: str = "" + + @field_validator("name", "model_number", "device_type", "frequency_range", "notes", mode="before") + @classmethod + def normalize_text_fields(cls, value: object) -> str: + return normalize_text(value) + + +class KnowledgeDeviceCreate(KnowledgeDeviceBase): + pass + + +class KnowledgeDeviceUpdate(KnowledgeDeviceBase): + pass + + +class KnowledgeDeviceResponse(KnowledgeDeviceBase): + id: int + slug: str + created_at: datetime + updated_at: datetime + manufacturer: KnowledgeManufacturerResponse + + model_config = ConfigDict(from_attributes=True) + + +class KnowledgeDocumentBase(BaseModel): + manufacturer_id: int + device_id: int | None = None + title: str = Field(min_length=2, max_length=255) + document_type: DocumentType + language: str = Field(default="de", min_length=2, max_length=20) + external_url: HttpUrl | None = None + paperless_document_id: str = Field(default="", max_length=120) + description: str = "" + tags: list[str] = Field(default_factory=list) + + @field_validator("title", "language", "paperless_document_id", "description", mode="before") + @classmethod + def normalize_text_fields(cls, value: object) -> str: + return normalize_text(value) + + @field_validator("tags", mode="before") + @classmethod + def normalize_tag_fields(cls, value: object) -> list[str]: + return normalize_tags(value) + + +class KnowledgeDocumentCreate(KnowledgeDocumentBase): + pass + + +class KnowledgeDocumentUpdate(KnowledgeDocumentBase): + pass + + +class KnowledgeDocumentResponse(KnowledgeDocumentBase): + id: int + slug: str + external_url: str + file_name: str + file_path: str + mime_type: str + file_size: int + checksum_sha256: str + created_at: datetime + updated_at: datetime + manufacturer: KnowledgeManufacturerResponse + device: KnowledgeDeviceResponse | None = None + + model_config = ConfigDict(from_attributes=True) + + +class KnowledgeNoteBase(BaseModel): + manufacturer_id: int | None = None + device_id: int | None = None + title: str = Field(min_length=2, max_length=255) + content: str = Field(min_length=1) + note_type: NoteType = "general" + severity: NoteSeverity = "" + tags: list[str] = Field(default_factory=list) + + @field_validator("title", "content", "severity", mode="before") + @classmethod + def normalize_text_fields(cls, value: object) -> str: + return normalize_text(value) + + @field_validator("tags", mode="before") + @classmethod + def normalize_tag_fields(cls, value: object) -> list[str]: + return normalize_tags(value) + + +class KnowledgeNoteCreate(KnowledgeNoteBase): + pass + + +class KnowledgeNoteUpdate(KnowledgeNoteBase): + pass + + +class KnowledgeNoteResponse(KnowledgeNoteBase): + id: int + created_at: datetime + updated_at: datetime + manufacturer: KnowledgeManufacturerResponse | None = None + device: KnowledgeDeviceResponse | None = None + + model_config = ConfigDict(from_attributes=True) + + +class KnowledgeSearchResult(BaseModel): + entity_type: str + id: int + title: str + subtitle: str = "" + href: str diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py index c415107..cd0a7fb 100644 --- a/backend/hermes/app/services/audit_service.py +++ b/backend/hermes/app/services/audit_service.py @@ -131,6 +131,19 @@ def action_title(action: str) -> str: "customer_contacts.update": "Ansprechpartner bearbeitet", "customer_contacts.delete": "Ansprechpartner gelöscht", "users.initial_admin_bootstrap": "Initialer Administrator erstellt", + "knowledge.manufacturers.create": "Hersteller erstellt", + "knowledge.manufacturers.update": "Hersteller bearbeitet", + "knowledge.manufacturers.delete": "Hersteller gelöscht", + "knowledge.devices.create": "Gerät erstellt", + "knowledge.devices.update": "Gerät bearbeitet", + "knowledge.devices.delete": "Gerät gelöscht", + "knowledge.documents.create": "Dokument erstellt", + "knowledge.documents.upload": "Dokument hochgeladen", + "knowledge.documents.update": "Dokument bearbeitet", + "knowledge.documents.delete": "Dokument gelöscht", + "knowledge.notes.create": "Wissensnotiz erstellt", + "knowledge.notes.update": "Wissensnotiz bearbeitet", + "knowledge.notes.delete": "Wissensnotiz gelöscht", } return labels.get(action, action) diff --git a/backend/hermes/app/services/knowledge_service.py b/backend/hermes/app/services/knowledge_service.py new file mode 100644 index 0000000..1b58441 --- /dev/null +++ b/backend/hermes/app/services/knowledge_service.py @@ -0,0 +1,247 @@ +import hashlib +import mimetypes +import re +import uuid +from pathlib import Path + +from fastapi import HTTPException, UploadFile, status +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.models.knowledge import KnowledgeDevice, KnowledgeDocument, KnowledgeManufacturer, KnowledgeNote +from app.repositories.knowledge_repository import KnowledgeRepository +from app.schemas.knowledge import ( + KnowledgeDeviceCreate, + KnowledgeDeviceUpdate, + KnowledgeDocumentCreate, + KnowledgeDocumentUpdate, + KnowledgeManufacturerCreate, + KnowledgeManufacturerUpdate, + KnowledgeNoteCreate, + KnowledgeNoteUpdate, +) + +ALLOWED_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".txt", ".zip"} +ALLOWED_MIME_TYPES = { + "application/pdf", + "image/jpeg", + "image/png", + "image/webp", + "text/plain", + "application/zip", + "application/x-zip-compressed", +} + + +def slugify(value: str) -> str: + normalized = value.strip().lower() + normalized = normalized.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss") + normalized = re.sub(r"[^a-z0-9]+", "-", normalized) + normalized = normalized.strip("-") + return normalized or uuid.uuid4().hex[:10] + + +def unique_slug(db: Session, base: str, exists) -> str: + slug = slugify(base) + candidate = slug + index = 2 + while exists(db, candidate): + candidate = f"{slug}-{index}" + index += 1 + return candidate + + +def storage_root() -> Path: + root = Path(settings.knowledge_storage_path).resolve() + root.mkdir(parents=True, exist_ok=True) + return root + + +def assert_safe_path(path: Path) -> Path: + root = storage_root() + resolved = path.resolve() + if root != resolved and root not in resolved.parents: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad") + return resolved + + +def safe_file_name(file_name: str) -> str: + name = Path(file_name).name.strip() + stem = slugify(Path(name).stem) + suffix = Path(name).suffix.lower() + return f"{stem}{suffix}" if suffix else stem + + +def parse_tags(value: str) -> list[str]: + seen: set[str] = set() + tags: list[str] = [] + for item in value.split(","): + tag = item.strip() + key = tag.lower() + if tag and key not in seen: + seen.add(key) + tags.append(tag) + return tags + + +async def read_upload(file: UploadFile) -> tuple[bytes, str, str]: + original_name = file.filename or "" + file_name = safe_file_name(original_name) + extension = Path(file_name).suffix.lower() + if extension not in ALLOWED_EXTENSIONS: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Dateityp ist nicht erlaubt") + + max_bytes = max(1, settings.knowledge_max_upload_mb) * 1024 * 1024 + content = await file.read(max_bytes + 1) + if not content: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Upload-Datei ist leer") + if len(content) > max_bytes: + raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="Upload-Datei ist zu groß") + + mime_type = file.content_type or mimetypes.guess_type(file_name)[0] or "application/octet-stream" + if mime_type not in ALLOWED_MIME_TYPES: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="MIME-Type ist nicht erlaubt") + + return content, file_name, mime_type + + +class KnowledgeService: + @staticmethod + def create_manufacturer(db: Session, payload: KnowledgeManufacturerCreate) -> KnowledgeManufacturer: + if KnowledgeRepository.get_manufacturer_by_name(db, payload.name): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Herstellername ist bereits vergeben") + slug = unique_slug(db, payload.name, lambda session, value: KnowledgeRepository.get_manufacturer_by_slug(session, value) is not None) + manufacturer = KnowledgeManufacturer(name=payload.name, slug=slug, website=str(payload.website or ""), notes=payload.notes) + db.add(manufacturer) + db.commit() + db.refresh(manufacturer) + return manufacturer + + @staticmethod + def update_manufacturer(db: Session, manufacturer: KnowledgeManufacturer, payload: KnowledgeManufacturerUpdate) -> KnowledgeManufacturer: + if KnowledgeRepository.get_manufacturer_by_name(db, payload.name, manufacturer.id): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Herstellername ist bereits vergeben") + manufacturer.name = payload.name + manufacturer.slug = unique_slug( + db, + payload.name, + lambda session, value: KnowledgeRepository.get_manufacturer_by_slug(session, value, manufacturer.id) is not None, + ) + manufacturer.website = str(payload.website or "") + manufacturer.notes = payload.notes + db.commit() + db.refresh(manufacturer) + return manufacturer + + @staticmethod + def create_device(db: Session, payload: KnowledgeDeviceCreate) -> KnowledgeDevice: + if KnowledgeRepository.get_manufacturer(db, payload.manufacturer_id) is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden") + slug = slugify(payload.model_number or payload.name) + if KnowledgeRepository.get_device_by_slug(db, manufacturer_id=payload.manufacturer_id, slug=slug): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Geräte-Slug ist für diesen Hersteller bereits vergeben") + device = KnowledgeDevice(slug=slug, **payload.model_dump()) + db.add(device) + db.commit() + return KnowledgeRepository.get_device(db, device.id) or device + + @staticmethod + def update_device(db: Session, device: KnowledgeDevice, payload: KnowledgeDeviceUpdate) -> KnowledgeDevice: + if KnowledgeRepository.get_manufacturer(db, payload.manufacturer_id) is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden") + slug = slugify(payload.model_number or payload.name) + if KnowledgeRepository.get_device_by_slug(db, manufacturer_id=payload.manufacturer_id, slug=slug, exclude_id=device.id): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Geräte-Slug ist für diesen Hersteller bereits vergeben") + for key, value in payload.model_dump().items(): + setattr(device, key, value) + device.slug = slug + db.commit() + return KnowledgeRepository.get_device(db, device.id) or device + + @staticmethod + def create_document(db: Session, payload: KnowledgeDocumentCreate) -> KnowledgeDocument: + KnowledgeService._validate_document_links(db, payload.manufacturer_id, payload.device_id) + slug = unique_slug(db, payload.title, lambda session, value: KnowledgeRepository.get_document_by_slug(session, value) is not None) + document = KnowledgeDocument(slug=slug, external_url=str(payload.external_url or ""), **payload.model_dump(exclude={"external_url"})) + db.add(document) + db.commit() + return KnowledgeRepository.get_document(db, document.id) or document + + @staticmethod + async def upload_document( + db: Session, + *, + file: UploadFile, + payload: KnowledgeDocumentCreate, + ) -> KnowledgeDocument: + KnowledgeService._validate_document_links(db, payload.manufacturer_id, payload.device_id) + content, file_name, mime_type = await read_upload(file) + checksum = hashlib.sha256(content).hexdigest() + slug = unique_slug(db, payload.title, lambda session, value: KnowledgeRepository.get_document_by_slug(session, value) is not None) + target_dir = storage_root() / str(payload.manufacturer_id) + target_dir.mkdir(parents=True, exist_ok=True) + stored_name = f"{uuid.uuid4().hex}-{file_name}" + file_path = assert_safe_path(target_dir / stored_name) + file_path.write_bytes(content) + document = KnowledgeDocument( + slug=slug, + external_url=str(payload.external_url or ""), + file_name=file_name, + file_path=str(file_path), + mime_type=mime_type, + file_size=len(content), + checksum_sha256=checksum, + **payload.model_dump(exclude={"external_url"}), + ) + db.add(document) + db.commit() + return KnowledgeRepository.get_document(db, document.id) or document + + @staticmethod + def update_document(db: Session, document: KnowledgeDocument, payload: KnowledgeDocumentUpdate) -> KnowledgeDocument: + KnowledgeService._validate_document_links(db, payload.manufacturer_id, payload.device_id) + slug = unique_slug(db, payload.title, lambda session, value: KnowledgeRepository.get_document_by_slug(session, value, document.id) is not None) + for key, value in payload.model_dump(exclude={"external_url"}).items(): + setattr(document, key, value) + document.external_url = str(payload.external_url or "") + document.slug = slug + db.commit() + return KnowledgeRepository.get_document(db, document.id) or document + + @staticmethod + def create_note(db: Session, payload: KnowledgeNoteCreate) -> KnowledgeNote: + KnowledgeService._validate_optional_links(db, payload.manufacturer_id, payload.device_id) + note = KnowledgeNote(**payload.model_dump()) + db.add(note) + db.commit() + return KnowledgeRepository.get_note(db, note.id) or note + + @staticmethod + def update_note(db: Session, note: KnowledgeNote, payload: KnowledgeNoteUpdate) -> KnowledgeNote: + KnowledgeService._validate_optional_links(db, payload.manufacturer_id, payload.device_id) + for key, value in payload.model_dump().items(): + setattr(note, key, value) + db.commit() + return KnowledgeRepository.get_note(db, note.id) or note + + @staticmethod + def _validate_document_links(db: Session, manufacturer_id: int, device_id: int | None) -> None: + if KnowledgeRepository.get_manufacturer(db, manufacturer_id) is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden") + if device_id is not None: + device = KnowledgeRepository.get_device(db, device_id) + if device is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gerät nicht gefunden") + if device.manufacturer_id != manufacturer_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Gerät gehört nicht zum Hersteller") + + @staticmethod + def _validate_optional_links(db: Session, manufacturer_id: int | None, device_id: int | None) -> None: + if manufacturer_id is not None and KnowledgeRepository.get_manufacturer(db, manufacturer_id) is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden") + if device_id is not None: + device = KnowledgeRepository.get_device(db, device_id) + if device is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gerät nicht gefunden") + if manufacturer_id is not None and device.manufacturer_id != manufacturer_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Gerät gehört nicht zum Hersteller") diff --git a/backend/hermes/docker-compose.yml b/backend/hermes/docker-compose.yml index 3c5b90e..dfd941a 100644 --- a/backend/hermes/docker-compose.yml +++ b/backend/hermes/docker-compose.yml @@ -17,6 +17,11 @@ services: ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} JWT_ISSUER: ${JWT_ISSUER:-hermes} LOG_LEVEL: ${LOG_LEVEL:-INFO} + KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge} + KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50} + + volumes: + - knowledge-data:/data/knowledge ports: - "8000:8000" @@ -28,3 +33,6 @@ services: networks: olympus-network: external: true + +volumes: + knowledge-data: diff --git a/docker-compose.yml b/docker-compose.yml index 68d7f53..bada19e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,11 @@ services: INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-} INITIAL_ADMIN_FIRST_NAME: ${INITIAL_ADMIN_FIRST_NAME:-} INITIAL_ADMIN_LAST_NAME: ${INITIAL_ADMIN_LAST_NAME:-} + KNOWLEDGE_STORAGE_PATH: ${KNOWLEDGE_STORAGE_PATH:-/data/knowledge} + KNOWLEDGE_MAX_UPLOAD_MB: ${KNOWLEDGE_MAX_UPLOAD_MB:-50} + + volumes: + - knowledge-data:/data/knowledge expose: - "8000" @@ -53,3 +58,6 @@ services: networks: olympus-network: external: true + +volumes: + knowledge-data: diff --git a/frontend/athena/app/api/knowledge/[...path]/route.ts b/frontend/athena/app/api/knowledge/[...path]/route.ts new file mode 100644 index 0000000..8b25e78 --- /dev/null +++ b/frontend/athena/app/api/knowledge/[...path]/route.ts @@ -0,0 +1,51 @@ +import { NextRequest } from "next/server"; + +import { assertSameOrigin } from "@/lib/server/request-guards"; +import { proxyHermesRequest, proxyHermesStreamRequest } from "@/lib/server/hermes-proxy"; + +type Params = { + params: Promise<{ + path: string[]; + }>; +}; + +async function knowledgePath(request: NextRequest, context: Params) { + const { path } = await context.params; + return `/knowledge/${path.join("/")}${request.nextUrl.search}`; +} + +function isDownloadPath(path: string) { + return /\/documents\/\d+\/download(?:\?|$)/.test(path); +} + +export async function GET(request: NextRequest, context: Params) { + const path = await knowledgePath(request, context); + if (isDownloadPath(path)) { + return proxyHermesStreamRequest(request, path); + } + return proxyHermesRequest(request, path); +} + +export async function POST(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + if (originError) { + return originError; + } + return proxyHermesRequest(request, await knowledgePath(request, context)); +} + +export async function PUT(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + if (originError) { + return originError; + } + return proxyHermesRequest(request, await knowledgePath(request, context)); +} + +export async function DELETE(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + if (originError) { + return originError; + } + return proxyHermesRequest(request, await knowledgePath(request, context)); +} diff --git a/frontend/athena/app/knowledge/devices/[id]/page.tsx b/frontend/athena/app/knowledge/devices/[id]/page.tsx new file mode 100644 index 0000000..6b9d0d3 --- /dev/null +++ b/frontend/athena/app/knowledge/devices/[id]/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { Download } from "lucide-react"; + +import DetailSection from "@/components/common/DetailSection"; +import DocumentTypeBadge from "@/components/knowledge/DocumentTypeBadge"; +import TagList from "@/components/knowledge/TagList"; +import { buttonVariants } from "@/components/ui/button"; +import { api } from "@/lib/api"; +import type { KnowledgeDevice, KnowledgeDocument, KnowledgeNote } from "@/types/knowledge"; + +export default function KnowledgeDeviceDetailPage() { + const params = useParams<{ id: string }>(); + const [device, setDevice] = useState(null); + const [documents, setDocuments] = useState([]); + const [notes, setNotes] = useState([]); + const [error, setError] = useState(""); + + useEffect(() => { + Promise.all([ + api.get(`/knowledge/devices/${params.id}`), + api.get(`/knowledge/documents?device_id=${params.id}&limit=100`), + api.get(`/knowledge/notes?device_id=${params.id}&limit=100`), + ]).then(([deviceResponse, documentResponse, noteResponse]) => { + setDevice(deviceResponse.data); + setDocuments(documentResponse.data); + setNotes(noteResponse.data); + }).catch(() => setError("Gerät konnte nicht geladen werden")); + }, [params.id]); + + const yearRange = useMemo(() => { + if (!device) return "-"; + if (!device.production_year_from && !device.production_year_to) return "-"; + return `${device.production_year_from ?? "?"} - ${device.production_year_to ?? "?"}`; + }, [device]); + + if (error) return

{error}

; + if (!device) return

Gerät wird geladen...

; + + return ( +
+
+

{device.name}

+

{device.manufacturer.name}{device.model_number ? ` · ${device.model_number}` : ""}

+
+ +
+
Gerätetyp
{device.device_type || "-"}
+
Frequenzbereich
{device.frequency_range || "-"}
+
Baujahre
{yearRange}
+
Slug
{device.slug}
+
+ {device.notes &&

{device.notes}

} +
+ +
+ {documents.map((document) => ( +
+
+ {document.title} +
+
+ {document.file_name && } +
+ ))} + {documents.length === 0 &&

Keine verknüpften Dokumente.

} +
+
+ +
+ {notes.map((note) => ( +
+

{note.title}

+

{note.content}

+
+ ))} + {notes.length === 0 &&

Keine verknüpften Notizen.

} +
+
+
+ ); +} diff --git a/frontend/athena/app/knowledge/devices/page.tsx b/frontend/athena/app/knowledge/devices/page.tsx new file mode 100644 index 0000000..e718d4b --- /dev/null +++ b/frontend/athena/app/knowledge/devices/page.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { Edit, Eye, Plus, Trash2 } from "lucide-react"; + +import ConfirmDialog from "@/components/common/ConfirmDialog"; +import DataTable, { type DataTableColumn } from "@/components/common/DataTable"; +import SearchInput from "@/components/common/SearchInput"; +import { useToast } from "@/components/common/ToastProvider"; +import { DeviceFormDialog } from "@/components/knowledge/KnowledgeForms"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { api } from "@/lib/api"; +import type { DevicePayload, KnowledgeDevice, KnowledgeManufacturer } from "@/types/knowledge"; + +function errorMessage(error: unknown) { + if (typeof error === "object" && error !== null && "response" in error) { + return (error as { response?: { data?: { detail?: string } } }).response?.data?.detail ?? "Aktion fehlgeschlagen"; + } + return "Aktion fehlgeschlagen"; +} + +export default function KnowledgeDevicesPage() { + const { showToast } = useToast(); + const [devices, setDevices] = useState([]); + const [manufacturers, setManufacturers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [formError, setFormError] = useState(""); + const [pending, setPending] = useState(false); + const [search, setSearch] = useState(""); + const [manufacturerFilter, setManufacturerFilter] = useState("all"); + const [sortKey, setSortKey] = useState("name"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + const [formOpen, setFormOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [deleteItem, setDeleteItem] = useState(null); + + const loadItems = useCallback(async () => { + setLoading(true); + try { + const [manufacturerResponse, deviceResponse] = await Promise.all([ + api.get("/knowledge/manufacturers?limit=100"), + api.get("/knowledge/devices?limit=100"), + ]); + setManufacturers(manufacturerResponse.data); + setDevices(deviceResponse.data); + setError(""); + } catch (err) { + setError(errorMessage(err)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + queueMicrotask(() => { + void loadItems(); + }); + }, [loadItems]); + + const rows = useMemo(() => devices + .filter((item) => manufacturerFilter === "all" || item.manufacturer_id === Number(manufacturerFilter)) + .filter((item) => [item.name, item.model_number, item.device_type, item.manufacturer.name].some((value) => value.toLowerCase().includes(search.toLowerCase()))) + .sort((a, b) => { + const av = sortKey === "manufacturer" ? a.manufacturer.name : String(a[sortKey as keyof KnowledgeDevice] ?? ""); + const bv = sortKey === "manufacturer" ? b.manufacturer.name : String(b[sortKey as keyof KnowledgeDevice] ?? ""); + const result = av.toLowerCase() > bv.toLowerCase() ? 1 : av.toLowerCase() < bv.toLowerCase() ? -1 : 0; + return sortDirection === "asc" ? result : -result; + }), [devices, manufacturerFilter, search, sortDirection, sortKey]); + + const columns: DataTableColumn[] = [ + { key: "manufacturer", label: "Hersteller", sortable: true, render: (item) => item.manufacturer.name }, + { key: "name", label: "Gerät", sortable: true, render: (item) => {item.name} }, + { key: "model_number", label: "Modell", sortable: true, render: (item) => item.model_number || "-" }, + { key: "device_type", label: "Typ", sortable: true, render: (item) => item.device_type || "-" }, + { key: "frequency_range", label: "Frequenz", render: (item) => item.frequency_range || "-" }, + { + key: "actions", + label: "Aktionen", + className: "px-4 py-3 text-right", + render: (item) => ( +
+ + + +
+ ), + }, + ]; + + async function save(payload: DevicePayload) { + setPending(true); + setFormError(""); + try { + if (selected) { + await api.put(`/knowledge/devices/${selected.id}`, payload); + showToast({ type: "success", title: "Gerät aktualisiert", description: payload.name }); + } else { + await api.post("/knowledge/devices", payload); + showToast({ type: "success", title: "Gerät erstellt", description: payload.name }); + } + setFormOpen(false); + await loadItems(); + } catch (err) { + const message = errorMessage(err); + setFormError(message); + showToast({ type: "error", title: "Gerät konnte nicht gespeichert werden", description: message }); + } finally { + setPending(false); + } + } + + async function confirmDelete() { + if (!deleteItem) return; + setPending(true); + try { + await api.delete(`/knowledge/devices/${deleteItem.id}`); + showToast({ type: "success", title: "Gerät gelöscht", description: deleteItem.name }); + setDeleteItem(null); + await loadItems(); + } catch (err) { + showToast({ type: "error", title: "Gerät konnte nicht gelöscht werden", description: errorMessage(err) }); + } finally { + setPending(false); + } + } + + return ( +
+
+

Geräte

{rows.length} Modelle

+ +
+
+ + +
+ item.id} sortKey={sortKey} sortDirection={sortDirection} loading={loading} error={error} emptyTitle="Keine Geräte" emptyDescription="Lege zunächst Hersteller und Geräte an." onSort={(key) => { setSortKey(key); setSortDirection((current) => sortKey === key && current === "asc" ? "desc" : "asc"); }} /> + + !open && setDeleteItem(null)} onConfirm={confirmDelete}> + {deleteItem &&

{deleteItem.name}

} +
+
+ ); +} diff --git a/frontend/athena/app/knowledge/documents/[id]/page.tsx b/frontend/athena/app/knowledge/documents/[id]/page.tsx new file mode 100644 index 0000000..6e06402 --- /dev/null +++ b/frontend/athena/app/knowledge/documents/[id]/page.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { Download, ExternalLink } from "lucide-react"; + +import DetailSection from "@/components/common/DetailSection"; +import DocumentTypeBadge from "@/components/knowledge/DocumentTypeBadge"; +import TagList from "@/components/knowledge/TagList"; +import { buttonVariants } from "@/components/ui/button"; +import { api } from "@/lib/api"; +import type { KnowledgeDocument } from "@/types/knowledge"; + +function fileSize(value: number) { + if (!value) return "-"; + if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`; + return `${(value / 1024 / 1024).toFixed(1)} MB`; +} + +export default function KnowledgeDocumentDetailPage() { + const params = useParams<{ id: string }>(); + const [document, setDocument] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + api.get(`/knowledge/documents/${params.id}`) + .then((response) => setDocument(response.data)) + .catch(() => setError("Dokument konnte nicht geladen werden")); + }, [params.id]); + + if (error) return

{error}

; + if (!document) return

Dokument wird geladen...

; + + return ( +
+
+
+

{document.title}

+

{document.manufacturer.name}{document.device ? ` · ${document.device.name}` : ""}

+
+
+ {document.external_url && Extern} + {document.file_name && Download} +
+
+ +
+
Typ
+
Sprache
{document.language}
+
Dateiname
{document.file_name || "-"}
+
Dateigröße
{fileSize(document.file_size)}
+
SHA-256
{document.checksum_sha256 || "-"}
+
Paperless-ID
{document.paperless_document_id || "-"}
+
+
+ {document.description &&

{document.description}

} +
+
+ ); +} diff --git a/frontend/athena/app/knowledge/documents/page.tsx b/frontend/athena/app/knowledge/documents/page.tsx new file mode 100644 index 0000000..3315f53 --- /dev/null +++ b/frontend/athena/app/knowledge/documents/page.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { Download, Edit, Eye, Plus, Trash2 } from "lucide-react"; + +import ConfirmDialog from "@/components/common/ConfirmDialog"; +import DataTable, { type DataTableColumn } from "@/components/common/DataTable"; +import SearchInput from "@/components/common/SearchInput"; +import { useToast } from "@/components/common/ToastProvider"; +import DocumentTypeBadge from "@/components/knowledge/DocumentTypeBadge"; +import { DocumentUploadDialog } from "@/components/knowledge/KnowledgeForms"; +import TagList from "@/components/knowledge/TagList"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { api } from "@/lib/api"; +import type { DocumentPayload, DocumentType, KnowledgeDevice, KnowledgeDocument, KnowledgeManufacturer } from "@/types/knowledge"; + +function errorMessage(error: unknown) { + if (typeof error === "object" && error !== null && "response" in error) { + return (error as { response?: { data?: { detail?: string } } }).response?.data?.detail ?? "Aktion fehlgeschlagen"; + } + return "Aktion fehlgeschlagen"; +} + +function fileSize(value: number) { + if (!value) return "-"; + if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`; + return `${(value / 1024 / 1024).toFixed(1)} MB`; +} + +export default function KnowledgeDocumentsPage() { + const { showToast } = useToast(); + const [documents, setDocuments] = useState([]); + const [manufacturers, setManufacturers] = useState([]); + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [formError, setFormError] = useState(""); + const [pending, setPending] = useState(false); + const [search, setSearch] = useState(""); + const [manufacturerFilter, setManufacturerFilter] = useState("all"); + const [typeFilter, setTypeFilter] = useState("all"); + const [sortKey, setSortKey] = useState("created_at"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); + const [formOpen, setFormOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [deleteItem, setDeleteItem] = useState(null); + + const loadItems = useCallback(async () => { + setLoading(true); + try { + const [manufacturerResponse, deviceResponse, documentResponse] = await Promise.all([ + api.get("/knowledge/manufacturers?limit=100"), + api.get("/knowledge/devices?limit=100"), + api.get("/knowledge/documents?limit=100"), + ]); + setManufacturers(manufacturerResponse.data); + setDevices(deviceResponse.data); + setDocuments(documentResponse.data); + setError(""); + } catch (err) { + setError(errorMessage(err)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + queueMicrotask(() => { + void loadItems(); + }); + }, [loadItems]); + + const rows = useMemo(() => documents + .filter((item) => manufacturerFilter === "all" || item.manufacturer_id === Number(manufacturerFilter)) + .filter((item) => typeFilter === "all" || item.document_type === typeFilter) + .filter((item) => [item.title, item.manufacturer.name, item.device?.name ?? "", ...item.tags].some((value) => value.toLowerCase().includes(search.toLowerCase()))) + .sort((a, b) => { + const av = sortKey === "manufacturer" ? a.manufacturer.name : String(a[sortKey as keyof KnowledgeDocument] ?? ""); + const bv = sortKey === "manufacturer" ? b.manufacturer.name : String(b[sortKey as keyof KnowledgeDocument] ?? ""); + const result = av.toLowerCase() > bv.toLowerCase() ? 1 : av.toLowerCase() < bv.toLowerCase() ? -1 : 0; + return sortDirection === "asc" ? result : -result; + }), [documents, manufacturerFilter, search, sortDirection, sortKey, typeFilter]); + + const columns: DataTableColumn[] = [ + { key: "title", label: "Titel", sortable: true, render: (item) => {item.title} }, + { key: "manufacturer", label: "Hersteller", sortable: true, render: (item) => item.manufacturer.name }, + { key: "device", label: "Gerät", render: (item) => item.device?.name ?? "-" }, + { key: "document_type", label: "Typ", sortable: true, render: (item) => }, + { key: "language", label: "Sprache", sortable: true, render: (item) => item.language }, + { key: "file_size", label: "Größe", render: (item) => fileSize(item.file_size) }, + { key: "tags", label: "Tags", render: (item) => }, + { + key: "actions", + label: "Aktionen", + className: "px-4 py-3 text-right", + render: (item) => ( +
+ + {item.file_name && } + + +
+ ), + }, + ]; + + async function save(payload: DocumentPayload, file: File | null) { + setPending(true); + setFormError(""); + try { + if (selected) { + await api.put(`/knowledge/documents/${selected.id}`, payload); + showToast({ type: "success", title: "Dokument aktualisiert", description: payload.title }); + } else { + if (!file) return; + const formData = new FormData(); + Object.entries(payload).forEach(([key, value]) => { + if (key === "tags") formData.append(key, (value as string[]).join(",")); + else if (value !== null && value !== undefined) formData.append(key, String(value)); + }); + formData.append("file", file); + await api.post("/knowledge/documents/upload", formData); + showToast({ type: "success", title: "Dokument hochgeladen", description: payload.title }); + } + setFormOpen(false); + await loadItems(); + } catch (err) { + const message = errorMessage(err); + setFormError(message); + showToast({ type: "error", title: "Dokument konnte nicht gespeichert werden", description: message }); + } finally { + setPending(false); + } + } + + async function confirmDelete() { + if (!deleteItem) return; + setPending(true); + try { + await api.delete(`/knowledge/documents/${deleteItem.id}`); + showToast({ type: "success", title: "Dokument gelöscht", description: deleteItem.title }); + setDeleteItem(null); + await loadItems(); + } catch (err) { + showToast({ type: "error", title: "Dokument konnte nicht gelöscht werden", description: errorMessage(err) }); + } finally { + setPending(false); + } + } + + return ( +
+
+

Dokumente

{rows.length} Unterlagen

+ +
+
+ + + +
+ item.id} sortKey={sortKey} sortDirection={sortDirection} loading={loading} error={error} emptyTitle="Keine Dokumente" emptyDescription="Lade das erste Service-Dokument hoch." onSort={(key) => { setSortKey(key); setSortDirection((current) => sortKey === key && current === "asc" ? "desc" : "asc"); }} /> + + !open && setDeleteItem(null)} onConfirm={confirmDelete}> + {deleteItem &&

{deleteItem.title}

} +
+
+ ); +} diff --git a/frontend/athena/app/knowledge/manufacturers/page.tsx b/frontend/athena/app/knowledge/manufacturers/page.tsx new file mode 100644 index 0000000..ea15283 --- /dev/null +++ b/frontend/athena/app/knowledge/manufacturers/page.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Edit, Plus, Trash2 } from "lucide-react"; + +import ConfirmDialog from "@/components/common/ConfirmDialog"; +import DataTable, { type DataTableColumn } from "@/components/common/DataTable"; +import SearchInput from "@/components/common/SearchInput"; +import { useToast } from "@/components/common/ToastProvider"; +import { ManufacturerFormDialog } from "@/components/knowledge/KnowledgeForms"; +import { Button } from "@/components/ui/button"; +import { api } from "@/lib/api"; +import type { KnowledgeManufacturer, ManufacturerPayload } from "@/types/knowledge"; + +function errorMessage(error: unknown) { + if (typeof error === "object" && error !== null && "response" in error) { + return (error as { response?: { data?: { detail?: string } } }).response?.data?.detail ?? "Aktion fehlgeschlagen"; + } + return "Aktion fehlgeschlagen"; +} + +export default function KnowledgeManufacturersPage() { + const { showToast } = useToast(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [formError, setFormError] = useState(""); + const [pending, setPending] = useState(false); + const [search, setSearch] = useState(""); + const [sortKey, setSortKey] = useState("name"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + const [formOpen, setFormOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [deleteItem, setDeleteItem] = useState(null); + + const loadItems = useCallback(async () => { + setLoading(true); + try { + const response = await api.get("/knowledge/manufacturers?limit=100"); + setItems(response.data); + setError(""); + } catch (err) { + setError(errorMessage(err)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + queueMicrotask(() => { + void loadItems(); + }); + }, [loadItems]); + + const rows = useMemo(() => items + .filter((item) => item.name.toLowerCase().includes(search.toLowerCase())) + .sort((a, b) => { + const av = String(a[sortKey as keyof KnowledgeManufacturer] ?? "").toLowerCase(); + const bv = String(b[sortKey as keyof KnowledgeManufacturer] ?? "").toLowerCase(); + const result = av > bv ? 1 : av < bv ? -1 : 0; + return sortDirection === "asc" ? result : -result; + }), [items, search, sortDirection, sortKey]); + + const columns: DataTableColumn[] = [ + { key: "name", label: "Name", sortable: true, render: (item) => {item.name} }, + { key: "website", label: "Website", sortable: true, render: (item) => item.website || "-" }, + { key: "notes", label: "Notizen", render: (item) => item.notes || "-" }, + { + key: "actions", + label: "Aktionen", + className: "px-4 py-3 text-right", + render: (item) => ( +
+ + +
+ ), + }, + ]; + + async function save(payload: ManufacturerPayload) { + setPending(true); + setFormError(""); + try { + if (selected) { + await api.put(`/knowledge/manufacturers/${selected.id}`, payload); + showToast({ type: "success", title: "Hersteller aktualisiert", description: payload.name }); + } else { + await api.post("/knowledge/manufacturers", payload); + showToast({ type: "success", title: "Hersteller erstellt", description: payload.name }); + } + setFormOpen(false); + await loadItems(); + } catch (err) { + const message = errorMessage(err); + setFormError(message); + showToast({ type: "error", title: "Hersteller konnte nicht gespeichert werden", description: message }); + } finally { + setPending(false); + } + } + + async function confirmDelete() { + if (!deleteItem) return; + setPending(true); + try { + await api.delete(`/knowledge/manufacturers/${deleteItem.id}`); + showToast({ type: "success", title: "Hersteller gelöscht", description: deleteItem.name }); + setDeleteItem(null); + await loadItems(); + } catch (err) { + showToast({ type: "error", title: "Hersteller konnte nicht gelöscht werden", description: errorMessage(err) }); + } finally { + setPending(false); + } + } + + return ( +
+
+

Hersteller

{rows.length} Einträge

+ +
+
+ item.id} sortKey={sortKey} sortDirection={sortDirection} loading={loading} error={error} emptyTitle="Keine Hersteller" emptyDescription="Lege den ersten Hersteller an." onSort={(key) => { setSortKey(key); setSortDirection((current) => sortKey === key && current === "asc" ? "desc" : "asc"); }} /> + + !open && setDeleteItem(null)} onConfirm={confirmDelete}> + {deleteItem &&

{deleteItem.name}

} +
+
+ ); +} diff --git a/frontend/athena/app/knowledge/notes/page.tsx b/frontend/athena/app/knowledge/notes/page.tsx new file mode 100644 index 0000000..66f24fb --- /dev/null +++ b/frontend/athena/app/knowledge/notes/page.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Edit, Plus, Trash2 } from "lucide-react"; + +import ConfirmDialog from "@/components/common/ConfirmDialog"; +import DataTable, { type DataTableColumn } from "@/components/common/DataTable"; +import SearchInput from "@/components/common/SearchInput"; +import { useToast } from "@/components/common/ToastProvider"; +import { KnowledgeNoteFormDialog } from "@/components/knowledge/KnowledgeForms"; +import TagList from "@/components/knowledge/TagList"; +import { Button } from "@/components/ui/button"; +import { api } from "@/lib/api"; +import type { KnowledgeDevice, KnowledgeManufacturer, KnowledgeNote, NotePayload } from "@/types/knowledge"; + +function errorMessage(error: unknown) { + if (typeof error === "object" && error !== null && "response" in error) { + return (error as { response?: { data?: { detail?: string } } }).response?.data?.detail ?? "Aktion fehlgeschlagen"; + } + return "Aktion fehlgeschlagen"; +} + +const noteTypeLabels: Record = { + repair: "Reparaturhinweis", + known_fault: "Bekannter Fehler", + alignment: "Abgleichhinweis", + spare_part: "Ersatzteilhinweis", + general: "Allgemein", +}; + +export default function KnowledgeNotesPage() { + const { showToast } = useToast(); + const [notes, setNotes] = useState([]); + const [manufacturers, setManufacturers] = useState([]); + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [formError, setFormError] = useState(""); + const [pending, setPending] = useState(false); + const [search, setSearch] = useState(""); + const [sortKey, setSortKey] = useState("created_at"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); + const [formOpen, setFormOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [deleteItem, setDeleteItem] = useState(null); + + const loadItems = useCallback(async () => { + setLoading(true); + try { + const [manufacturerResponse, deviceResponse, noteResponse] = await Promise.all([ + api.get("/knowledge/manufacturers?limit=100"), + api.get("/knowledge/devices?limit=100"), + api.get("/knowledge/notes?limit=100"), + ]); + setManufacturers(manufacturerResponse.data); + setDevices(deviceResponse.data); + setNotes(noteResponse.data); + setError(""); + } catch (err) { + setError(errorMessage(err)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + queueMicrotask(() => { + void loadItems(); + }); + }, [loadItems]); + + const rows = useMemo(() => notes + .filter((item) => [item.title, item.content, item.device?.name ?? "", item.manufacturer?.name ?? "", ...item.tags].some((value) => value.toLowerCase().includes(search.toLowerCase()))) + .sort((a, b) => { + const av = String(a[sortKey as keyof KnowledgeNote] ?? ""); + const bv = String(b[sortKey as keyof KnowledgeNote] ?? ""); + const result = av.toLowerCase() > bv.toLowerCase() ? 1 : av.toLowerCase() < bv.toLowerCase() ? -1 : 0; + return sortDirection === "asc" ? result : -result; + }), [notes, search, sortDirection, sortKey]); + + const columns: DataTableColumn[] = [ + { key: "title", label: "Titel", sortable: true, render: (item) => {item.title} }, + { key: "note_type", label: "Typ", sortable: true, render: (item) => noteTypeLabels[item.note_type] ?? item.note_type }, + { key: "manufacturer", label: "Hersteller", render: (item) => item.manufacturer?.name ?? "-" }, + { key: "device", label: "Gerät", render: (item) => item.device?.name ?? "-" }, + { key: "severity", label: "Schweregrad", sortable: true, render: (item) => item.severity || "-" }, + { key: "tags", label: "Tags", render: (item) => }, + { + key: "actions", + label: "Aktionen", + className: "px-4 py-3 text-right", + render: (item) => ( +
+ + +
+ ), + }, + ]; + + async function save(payload: NotePayload) { + setPending(true); + setFormError(""); + try { + if (selected) { + await api.put(`/knowledge/notes/${selected.id}`, payload); + showToast({ type: "success", title: "Notiz aktualisiert", description: payload.title }); + } else { + await api.post("/knowledge/notes", payload); + showToast({ type: "success", title: "Notiz erstellt", description: payload.title }); + } + setFormOpen(false); + await loadItems(); + } catch (err) { + const message = errorMessage(err); + setFormError(message); + showToast({ type: "error", title: "Notiz konnte nicht gespeichert werden", description: message }); + } finally { + setPending(false); + } + } + + async function confirmDelete() { + if (!deleteItem) return; + setPending(true); + try { + await api.delete(`/knowledge/notes/${deleteItem.id}`); + showToast({ type: "success", title: "Notiz gelöscht", description: deleteItem.title }); + setDeleteItem(null); + await loadItems(); + } catch (err) { + showToast({ type: "error", title: "Notiz konnte nicht gelöscht werden", description: errorMessage(err) }); + } finally { + setPending(false); + } + } + + return ( +
+
+

Knowledge Notes

{rows.length} Reparatur- und Servicehinweise

+ +
+
+ item.id} sortKey={sortKey} sortDirection={sortDirection} loading={loading} error={error} emptyTitle="Keine Notizen" emptyDescription="Erstelle den ersten Reparaturhinweis." onSort={(key) => { setSortKey(key); setSortDirection((current) => sortKey === key && current === "asc" ? "desc" : "asc"); }} /> + + !open && setDeleteItem(null)} onConfirm={confirmDelete}> + {deleteItem &&

{deleteItem.title}

} +
+
+ ); +} diff --git a/frontend/athena/app/knowledge/page.tsx b/frontend/athena/app/knowledge/page.tsx new file mode 100644 index 0000000..75edafc --- /dev/null +++ b/frontend/athena/app/knowledge/page.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { BookOpen, Cpu, Factory, FileText, StickyNote } from "lucide-react"; + +import SearchInput from "@/components/common/SearchInput"; +import SummaryCard from "@/components/common/SummaryCard"; +import { buttonVariants } from "@/components/ui/button"; +import { api } from "@/lib/api"; +import type { KnowledgeDevice, KnowledgeDocument, KnowledgeManufacturer, KnowledgeNote, KnowledgeSearchResult } from "@/types/knowledge"; + +export default function KnowledgePage() { + const [manufacturers, setManufacturers] = useState([]); + const [devices, setDevices] = useState([]); + const [documents, setDocuments] = useState([]); + const [notes, setNotes] = useState([]); + const [results, setResults] = useState([]); + const [query, setQuery] = useState(""); + const [error, setError] = useState(""); + + useEffect(() => { + queueMicrotask(() => { + Promise.all([ + api.get("/knowledge/manufacturers?limit=100"), + api.get("/knowledge/devices?limit=100"), + api.get("/knowledge/documents?limit=10"), + api.get("/knowledge/notes?limit=10"), + ]).then(([manufacturerResponse, deviceResponse, documentResponse, noteResponse]) => { + setManufacturers(manufacturerResponse.data); + setDevices(deviceResponse.data); + setDocuments(documentResponse.data); + setNotes(noteResponse.data); + }).catch(() => setError("Wissensdatenbank konnte nicht geladen werden")); + }); + }, []); + + useEffect(() => { + const trimmed = query.trim(); + if (!trimmed) { + queueMicrotask(() => setResults([])); + return; + } + const timeout = window.setTimeout(() => { + api.get(`/knowledge/search?q=${encodeURIComponent(trimmed)}`) + .then((response) => setResults(response.data)) + .catch(() => setResults([])); + }, 250); + return () => window.clearTimeout(timeout); + }, [query]); + + const latestDocuments = useMemo(() => documents.slice(0, 5), [documents]); + const latestNotes = useMemo(() => notes.slice(0, 5), [notes]); + + return ( +
+
+
+

Wissensdatenbank

+

Serviceunterlagen, Modelle, Schaltpläne und Reparaturwissen

+
+
+ Hersteller + Geräte + Dokumente + Notizen +
+
+ +
+ + + + +
+ +
+ + {results.length > 0 && ( +
+ {results.map((result) => ( + + + {result.title} + {result.subtitle} + + ))} +
+ )} + {error &&

{error}

} +
+ +
+
+

Letzte Dokumente

+
+ {latestDocuments.map((document) => ( + +

{document.title}

+

{document.manufacturer.name}{document.device ? ` · ${document.device.name}` : ""}

+ + ))} + {latestDocuments.length === 0 &&

Noch keine Dokumente vorhanden.

} +
+
+
+

Letzte Notizen

+
+ {latestNotes.map((note) => ( + +

{note.title}

+

{note.content}

+ + ))} + {latestNotes.length === 0 &&

Noch keine Notizen vorhanden.

} +
+
+
+
+ ); +} diff --git a/frontend/athena/components/Sidebar.tsx b/frontend/athena/components/Sidebar.tsx index 7d2b98f..44a0e29 100644 --- a/frontend/athena/components/Sidebar.tsx +++ b/frontend/athena/components/Sidebar.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { + BookOpen, ClipboardList, FileText, LayoutDashboard, @@ -49,6 +50,12 @@ const menu = [ href: "/customers", permission: "customers.read", }, + { + icon: BookOpen, + name: "Wissensdatenbank", + href: "/knowledge", + permission: "knowledge.read", + }, { icon: Wrench, name: "Reparaturen", diff --git a/frontend/athena/components/knowledge/DocumentTypeBadge.tsx b/frontend/athena/components/knowledge/DocumentTypeBadge.tsx new file mode 100644 index 0000000..fd7cd9c --- /dev/null +++ b/frontend/athena/components/knowledge/DocumentTypeBadge.tsx @@ -0,0 +1,25 @@ +import type { DocumentType } from "@/types/knowledge"; + +const labels: Record = { + manual: "Bedienungsanleitung", + service_manual: "Service Manual", + schematic: "Schaltplan", + alignment: "Abgleich", + parts_list: "Ersatzteile", + firmware: "Firmware", + datasheet: "Datenblatt", + service_bulletin: "Bulletin", + other: "Sonstiges", +}; + +export function documentTypeLabel(type: DocumentType) { + return labels[type] ?? type; +} + +export default function DocumentTypeBadge({ type }: { type: DocumentType }) { + return ( + + {documentTypeLabel(type)} + + ); +} diff --git a/frontend/athena/components/knowledge/KnowledgeForms.tsx b/frontend/athena/components/knowledge/KnowledgeForms.tsx new file mode 100644 index 0000000..1950625 --- /dev/null +++ b/frontend/athena/components/knowledge/KnowledgeForms.tsx @@ -0,0 +1,325 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { + DevicePayload, + DocumentPayload, + DocumentType, + KnowledgeDevice, + KnowledgeDocument, + KnowledgeManufacturer, + KnowledgeNote, + ManufacturerPayload, + NotePayload, + NoteType, +} from "@/types/knowledge"; + +const documentTypes: Array<[DocumentType, string]> = [ + ["manual", "Bedienungsanleitung"], + ["service_manual", "Service Manual"], + ["schematic", "Schaltplan"], + ["alignment", "Abgleichanleitung"], + ["parts_list", "Ersatzteilliste"], + ["firmware", "Firmware"], + ["datasheet", "Datenblatt"], + ["service_bulletin", "Service Bulletin"], + ["other", "Sonstiges"], +]; + +const noteTypes: Array<[NoteType, string]> = [ + ["repair", "Reparaturhinweis"], + ["known_fault", "Bekannter Fehler"], + ["alignment", "Abgleichhinweis"], + ["spare_part", "Ersatzteilhinweis"], + ["general", "Allgemein"], +]; + +function tagsToText(tags: string[]) { + return tags.join(", "); +} + +function textToTags(value: string) { + return value.split(",").map((tag) => tag.trim()).filter(Boolean); +} + +function fieldClass() { + return "grid gap-2"; +} + +export function ManufacturerFormDialog({ + open, + manufacturer, + pending, + error, + onOpenChange, + onSubmit, +}: { + open: boolean; + manufacturer: KnowledgeManufacturer | null; + pending: boolean; + error: string; + onOpenChange: (open: boolean) => void; + onSubmit: (payload: ManufacturerPayload) => void; +}) { + const [name, setName] = useState(""); + const [website, setWebsite] = useState(""); + const [notes, setNotes] = useState(""); + + useEffect(() => { + queueMicrotask(() => { + setName(manufacturer?.name ?? ""); + setWebsite(manufacturer?.website ?? ""); + setNotes(manufacturer?.notes ?? ""); + }); + }, [manufacturer, open]); + + return ( + + + {manufacturer ? "Hersteller bearbeiten" : "Hersteller erstellen"} +
+
setName(event.target.value)} />
+
setWebsite(event.target.value)} />
+