From 228da8f81488115d96645fa2bfcc315dbb443b2a Mon Sep 17 00:00:00 2001 From: DS | Schubert Date: Fri, 3 Jul 2026 10:25:52 +0200 Subject: [PATCH 01/21] 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)} />
+