feat(knowledge): add service library
This commit is contained in:
parent
ecab0fe6b6
commit
228da8f814
32 changed files with 2878 additions and 6 deletions
|
|
@ -12,3 +12,5 @@ INITIAL_ADMIN_EMAIL=
|
||||||
INITIAL_ADMIN_PASSWORD=
|
INITIAL_ADMIN_PASSWORD=
|
||||||
INITIAL_ADMIN_FIRST_NAME=
|
INITIAL_ADMIN_FIRST_NAME=
|
||||||
INITIAL_ADMIN_LAST_NAME=
|
INITIAL_ADMIN_LAST_NAME=
|
||||||
|
KNOWLEDGE_STORAGE_PATH=/data/knowledge
|
||||||
|
KNOWLEDGE_MAX_UPLOAD_MB=50
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
## Verzeichnisstruktur
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -564,6 +627,12 @@ docker-compose.yml
|
||||||
`LOG_LEVEL`
|
`LOG_LEVEL`
|
||||||
: Runtime-Loglevel fuer Hermes, z. B. `INFO`, `WARNING` oder `ERROR`.
|
: 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
|
### Athena
|
||||||
|
|
||||||
`HERMES_INTERNAL_URL`
|
`HERMES_INTERNAL_URL`
|
||||||
|
|
|
||||||
|
|
@ -220,10 +220,35 @@ Importmodi:
|
||||||
|
|
||||||
Die Excel-freundliche Vorlage liegt unter `GET /api/customers/import/template`.
|
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
|
### Deployment
|
||||||
|
|
||||||
Neue Installationen koennen optional ueber `INITIAL_ADMIN_*` einen initialen Administrator anlegen. Diese Variablen werden nur von Hermes gelesen und duerfen nicht im Frontend oder in Logs erscheinen.
|
Neue 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 Permissions
|
||||||
|
|
||||||
Neue Module muessen eigene stabile Permission-Strings erhalten.
|
Neue Module muessen eigene stabile Permission-Strings erhalten.
|
||||||
|
|
|
||||||
27
ROADMAP.md
27
ROADMAP.md
|
|
@ -44,7 +44,18 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
|
||||||
- Excel-freundliche CSV-Vorlage ueber Athena
|
- Excel-freundliche CSV-Vorlage ueber Athena
|
||||||
- Audit Log fuer Kundenimporte ohne CSV-Rohdaten
|
- 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
|
- Projektstammdaten
|
||||||
- Projektstatus und Verantwortliche
|
- Projektstatus und Verantwortliche
|
||||||
|
|
@ -52,7 +63,7 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
|
||||||
- RBAC-Permissions fuer Projekte
|
- RBAC-Permissions fuer Projekte
|
||||||
- Audit Logs fuer Projektaktionen
|
- Audit Logs fuer Projektaktionen
|
||||||
|
|
||||||
## v0.7.0 - Tickets, geplant
|
## v0.8.0 - Tickets, geplant
|
||||||
|
|
||||||
- Ticketverwaltung
|
- Ticketverwaltung
|
||||||
- Status- und Prioritaetsmodell
|
- Status- und Prioritaetsmodell
|
||||||
|
|
@ -60,15 +71,19 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
|
||||||
- RBAC-Permissions fuer Tickets
|
- RBAC-Permissions fuer Tickets
|
||||||
- Audit Logs fuer Ticketaktionen
|
- 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
|
- Aufgabenverwaltung
|
||||||
- Kalenderansicht
|
- Kalenderansicht
|
||||||
- Faelligkeiten und Verantwortlichkeiten
|
- Faelligkeiten und Verantwortlichkeiten
|
||||||
- Verknuepfung mit Kunden, Projekten und Tickets
|
- Verknuepfung mit Kunden, Projekten und Tickets
|
||||||
|
|
||||||
## v1.0.0 - Produktiver Beta-Release
|
|
||||||
|
|
||||||
- Gehaertete Produktionskonfiguration
|
- Gehaertete Produktionskonfiguration
|
||||||
- Vollstaendige Migrationsstrategie
|
- Vollstaendige Migrationsstrategie
|
||||||
- Deployment-Dokumentation
|
- Deployment-Dokumentation
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,5 @@ INITIAL_ADMIN_EMAIL=
|
||||||
INITIAL_ADMIN_PASSWORD=
|
INITIAL_ADMIN_PASSWORD=
|
||||||
INITIAL_ADMIN_FIRST_NAME=
|
INITIAL_ADMIN_FIRST_NAME=
|
||||||
INITIAL_ADMIN_LAST_NAME=
|
INITIAL_ADMIN_LAST_NAME=
|
||||||
|
KNOWLEDGE_STORAGE_PATH=/data/knowledge
|
||||||
|
KNOWLEDGE_MAX_UPLOAD_MB=50
|
||||||
|
|
|
||||||
219
backend/hermes/alembic/versions/b6f3d8c1a920_create_knowledge.py
Normal file
219
backend/hermes/alembic/versions/b6f3d8c1a920_create_knowledge.py
Normal file
|
|
@ -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")
|
||||||
336
backend/hermes/app/api/knowledge.py
Normal file
336
backend/hermes/app/api/knowledge.py
Normal file
|
|
@ -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]
|
||||||
|
|
@ -15,6 +15,8 @@ class Settings(BaseSettings):
|
||||||
initial_admin_password: str | None = None
|
initial_admin_password: str | None = None
|
||||||
initial_admin_first_name: str = ""
|
initial_admin_first_name: str = ""
|
||||||
initial_admin_last_name: str = ""
|
initial_admin_last_name: str = ""
|
||||||
|
knowledge_storage_path: str = "/data/knowledge"
|
||||||
|
knowledge_max_upload_mb: int = 50
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ class Base(DeclarativeBase):
|
||||||
# <<< HIER IMPORTIEREN >>>
|
# <<< HIER IMPORTIEREN >>>
|
||||||
import app.models.rbac
|
import app.models.rbac
|
||||||
import app.models.customer
|
import app.models.customer
|
||||||
|
import app.models.knowledge
|
||||||
import app.models.audit
|
import app.models.audit
|
||||||
import app.models.user
|
import app.models.user
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.audit import router as audit_router
|
||||||
from app.api.customers import router as customers_router
|
from app.api.customers import router as customers_router
|
||||||
from app.api.dashboard import router as dashboard_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.permissions import router as permissions_router
|
||||||
from app.api.roles import router as roles_router
|
from app.api.roles import router as roles_router
|
||||||
from app.api.users import router as users_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(roles_router)
|
||||||
app.include_router(permissions_router)
|
app.include_router(permissions_router)
|
||||||
app.include_router(customers_router)
|
app.include_router(customers_router)
|
||||||
|
app.include_router(knowledge_router)
|
||||||
app.include_router(dashboard_router)
|
app.include_router(dashboard_router)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
|
||||||
101
backend/hermes/app/models/knowledge.py
Normal file
101
backend/hermes/app/models/knowledge.py
Normal file
|
|
@ -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())
|
||||||
|
|
@ -70,6 +70,12 @@ STANDARD_PERMISSIONS = [
|
||||||
("system.settings.read", "Einstellungen lesen", "Systemeinstellungen anzeigen", "system"),
|
("system.settings.read", "Einstellungen lesen", "Systemeinstellungen anzeigen", "system"),
|
||||||
("system.settings.update", "Einstellungen bearbeiten", "Systemeinstellungen aktualisieren", "system"),
|
("system.settings.update", "Einstellungen bearbeiten", "Systemeinstellungen aktualisieren", "system"),
|
||||||
("audit_logs.read", "Audit Logs lesen", "Audit Logs anzeigen", "audit_logs"),
|
("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 = {
|
ROLE_PERMISSION_NAMES = {
|
||||||
|
|
@ -78,6 +84,8 @@ ROLE_PERMISSION_NAMES = {
|
||||||
"dashboard.read",
|
"dashboard.read",
|
||||||
"users.read",
|
"users.read",
|
||||||
"customers.read",
|
"customers.read",
|
||||||
|
"knowledge.read",
|
||||||
|
"knowledge.download",
|
||||||
"projects.read",
|
"projects.read",
|
||||||
"tickets.read",
|
"tickets.read",
|
||||||
},
|
},
|
||||||
|
|
@ -95,11 +103,18 @@ ROLE_PERMISSION_NAMES = {
|
||||||
"tickets.read",
|
"tickets.read",
|
||||||
"tickets.create",
|
"tickets.create",
|
||||||
"tickets.update",
|
"tickets.update",
|
||||||
|
"knowledge.read",
|
||||||
|
"knowledge.create",
|
||||||
|
"knowledge.update",
|
||||||
|
"knowledge.upload",
|
||||||
|
"knowledge.download",
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
"dashboard.read",
|
"dashboard.read",
|
||||||
"users.read",
|
"users.read",
|
||||||
"customers.read",
|
"customers.read",
|
||||||
|
"knowledge.read",
|
||||||
|
"knowledge.download",
|
||||||
"tickets.read",
|
"tickets.read",
|
||||||
"tickets.create",
|
"tickets.create",
|
||||||
"tickets.update",
|
"tickets.update",
|
||||||
|
|
|
||||||
177
backend/hermes/app/repositories/knowledge_repository.py
Normal file
177
backend/hermes/app/repositories/knowledge_repository.py
Normal file
|
|
@ -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))
|
||||||
|
)
|
||||||
200
backend/hermes/app/schemas/knowledge.py
Normal file
200
backend/hermes/app/schemas/knowledge.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -131,6 +131,19 @@ def action_title(action: str) -> str:
|
||||||
"customer_contacts.update": "Ansprechpartner bearbeitet",
|
"customer_contacts.update": "Ansprechpartner bearbeitet",
|
||||||
"customer_contacts.delete": "Ansprechpartner gelöscht",
|
"customer_contacts.delete": "Ansprechpartner gelöscht",
|
||||||
"users.initial_admin_bootstrap": "Initialer Administrator erstellt",
|
"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)
|
return labels.get(action, action)
|
||||||
|
|
||||||
|
|
|
||||||
247
backend/hermes/app/services/knowledge_service.py
Normal file
247
backend/hermes/app/services/knowledge_service.py
Normal file
|
|
@ -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")
|
||||||
|
|
@ -17,6 +17,11 @@ services:
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
||||||
JWT_ISSUER: ${JWT_ISSUER:-hermes}
|
JWT_ISSUER: ${JWT_ISSUER:-hermes}
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
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:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
|
@ -28,3 +33,6 @@ services:
|
||||||
networks:
|
networks:
|
||||||
olympus-network:
|
olympus-network:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
knowledge-data:
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,11 @@ services:
|
||||||
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-}
|
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-}
|
||||||
INITIAL_ADMIN_FIRST_NAME: ${INITIAL_ADMIN_FIRST_NAME:-}
|
INITIAL_ADMIN_FIRST_NAME: ${INITIAL_ADMIN_FIRST_NAME:-}
|
||||||
INITIAL_ADMIN_LAST_NAME: ${INITIAL_ADMIN_LAST_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:
|
expose:
|
||||||
- "8000"
|
- "8000"
|
||||||
|
|
@ -53,3 +58,6 @@ services:
|
||||||
networks:
|
networks:
|
||||||
olympus-network:
|
olympus-network:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
knowledge-data:
|
||||||
|
|
|
||||||
51
frontend/athena/app/api/knowledge/[...path]/route.ts
Normal file
51
frontend/athena/app/api/knowledge/[...path]/route.ts
Normal file
|
|
@ -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));
|
||||||
|
}
|
||||||
85
frontend/athena/app/knowledge/devices/[id]/page.tsx
Normal file
85
frontend/athena/app/knowledge/devices/[id]/page.tsx
Normal file
|
|
@ -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<KnowledgeDevice | null>(null);
|
||||||
|
const [documents, setDocuments] = useState<KnowledgeDocument[]>([]);
|
||||||
|
const [notes, setNotes] = useState<KnowledgeNote[]>([]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
api.get<KnowledgeDevice>(`/knowledge/devices/${params.id}`),
|
||||||
|
api.get<KnowledgeDocument[]>(`/knowledge/documents?device_id=${params.id}&limit=100`),
|
||||||
|
api.get<KnowledgeNote[]>(`/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 <p className="text-red-600">{error}</p>;
|
||||||
|
if (!device) return <p className="text-slate-500">Gerät wird geladen...</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-950">{device.name}</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">{device.manufacturer.name}{device.model_number ? ` · ${device.model_number}` : ""}</p>
|
||||||
|
</div>
|
||||||
|
<DetailSection title="Stammdaten">
|
||||||
|
<dl className="grid gap-3 text-sm md:grid-cols-2">
|
||||||
|
<div><dt className="text-slate-500">Gerätetyp</dt><dd className="font-medium">{device.device_type || "-"}</dd></div>
|
||||||
|
<div><dt className="text-slate-500">Frequenzbereich</dt><dd className="font-medium">{device.frequency_range || "-"}</dd></div>
|
||||||
|
<div><dt className="text-slate-500">Baujahre</dt><dd className="font-medium">{yearRange}</dd></div>
|
||||||
|
<div><dt className="text-slate-500">Slug</dt><dd className="font-medium">{device.slug}</dd></div>
|
||||||
|
</dl>
|
||||||
|
{device.notes && <p className="mt-4 whitespace-pre-wrap text-sm text-slate-700">{device.notes}</p>}
|
||||||
|
</DetailSection>
|
||||||
|
<DetailSection title="Dokumente">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{documents.map((document) => (
|
||||||
|
<div key={document.id} className="flex items-center justify-between gap-3 rounded-lg border p-3">
|
||||||
|
<div>
|
||||||
|
<Link href={`/knowledge/documents/${document.id}`} className="font-medium text-slate-950 hover:underline">{document.title}</Link>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2"><DocumentTypeBadge type={document.document_type} /><TagList tags={document.tags} /></div>
|
||||||
|
</div>
|
||||||
|
{document.file_name && <Link href={`/api/knowledge/documents/${document.id}/download`} className={buttonVariants({ variant: "outline", size: "icon-sm" })} title="Download"><Download size={16} /></Link>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{documents.length === 0 && <p className="text-sm text-slate-500">Keine verknüpften Dokumente.</p>}
|
||||||
|
</div>
|
||||||
|
</DetailSection>
|
||||||
|
<DetailSection title="Notizen">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{notes.map((note) => (
|
||||||
|
<div key={note.id} className="rounded-lg border p-3">
|
||||||
|
<p className="font-medium">{note.title}</p>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap text-sm text-slate-600">{note.content}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{notes.length === 0 && <p className="text-sm text-slate-500">Keine verknüpften Notizen.</p>}
|
||||||
|
</div>
|
||||||
|
</DetailSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
frontend/athena/app/knowledge/devices/page.tsx
Normal file
149
frontend/athena/app/knowledge/devices/page.tsx
Normal file
|
|
@ -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<KnowledgeDevice[]>([]);
|
||||||
|
const [manufacturers, setManufacturers] = useState<KnowledgeManufacturer[]>([]);
|
||||||
|
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<KnowledgeDevice | null>(null);
|
||||||
|
const [deleteItem, setDeleteItem] = useState<KnowledgeDevice | null>(null);
|
||||||
|
|
||||||
|
const loadItems = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [manufacturerResponse, deviceResponse] = await Promise.all([
|
||||||
|
api.get<KnowledgeManufacturer[]>("/knowledge/manufacturers?limit=100"),
|
||||||
|
api.get<KnowledgeDevice[]>("/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<KnowledgeDevice>[] = [
|
||||||
|
{ key: "manufacturer", label: "Hersteller", sortable: true, render: (item) => item.manufacturer.name },
|
||||||
|
{ key: "name", label: "Gerät", sortable: true, render: (item) => <span className="font-medium">{item.name}</span> },
|
||||||
|
{ 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) => (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Link href={`/knowledge/devices/${item.id}`} className={buttonVariants({ variant: "ghost", size: "icon-sm" })} title="Details"><Eye size={16} /></Link>
|
||||||
|
<Button variant="ghost" size="icon-sm" title="Bearbeiten" onClick={() => { setSelected(item); setFormError(""); setFormOpen(true); }}><Edit size={16} /></Button>
|
||||||
|
<Button variant="destructive" size="icon-sm" title="Löschen" onClick={() => setDeleteItem(item)}><Trash2 size={16} /></Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div><h1 className="text-3xl font-bold text-slate-950">Geräte</h1><p className="mt-1 text-sm text-slate-500">{rows.length} Modelle</p></div>
|
||||||
|
<Button onClick={() => { setSelected(null); setFormError(""); setFormOpen(true); }} disabled={manufacturers.length === 0}><Plus size={16} />Gerät</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 lg:flex-row">
|
||||||
|
<SearchInput value={search} onChange={setSearch} placeholder="Geräte suchen" />
|
||||||
|
<select className="h-8 rounded-lg border px-2 text-sm" value={manufacturerFilter} onChange={(event) => setManufacturerFilter(event.target.value)}>
|
||||||
|
<option value="all">Alle Hersteller</option>
|
||||||
|
{manufacturers.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<DataTable columns={columns} rows={rows} rowKey={(item) => 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"); }} />
|
||||||
|
<DeviceFormDialog open={formOpen} device={selected} manufacturers={manufacturers} pending={pending} error={formError} onOpenChange={setFormOpen} onSubmit={save} />
|
||||||
|
<ConfirmDialog open={Boolean(deleteItem)} title="Gerät löschen" description="Dieses Gerät wird gelöscht, sofern keine abhängigen Einträge blockieren." pending={pending} onOpenChange={(open) => !open && setDeleteItem(null)} onConfirm={confirmDelete}>
|
||||||
|
{deleteItem && <p className="text-sm font-medium">{deleteItem.name}</p>}
|
||||||
|
</ConfirmDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
frontend/athena/app/knowledge/documents/[id]/page.tsx
Normal file
61
frontend/athena/app/knowledge/documents/[id]/page.tsx
Normal file
|
|
@ -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<KnowledgeDocument | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<KnowledgeDocument>(`/knowledge/documents/${params.id}`)
|
||||||
|
.then((response) => setDocument(response.data))
|
||||||
|
.catch(() => setError("Dokument konnte nicht geladen werden"));
|
||||||
|
}, [params.id]);
|
||||||
|
|
||||||
|
if (error) return <p className="text-red-600">{error}</p>;
|
||||||
|
if (!document) return <p className="text-slate-500">Dokument wird geladen...</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-950">{document.title}</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">{document.manufacturer.name}{document.device ? ` · ${document.device.name}` : ""}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{document.external_url && <Link href={document.external_url} target="_blank" className={buttonVariants({ variant: "outline" })}><ExternalLink size={16} />Extern</Link>}
|
||||||
|
{document.file_name && <Link href={`/api/knowledge/documents/${document.id}/download`} className={buttonVariants()}><Download size={16} />Download</Link>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DetailSection title="Dokument">
|
||||||
|
<dl className="grid gap-3 text-sm md:grid-cols-2">
|
||||||
|
<div><dt className="text-slate-500">Typ</dt><dd className="mt-1"><DocumentTypeBadge type={document.document_type} /></dd></div>
|
||||||
|
<div><dt className="text-slate-500">Sprache</dt><dd className="font-medium">{document.language}</dd></div>
|
||||||
|
<div><dt className="text-slate-500">Dateiname</dt><dd className="font-medium">{document.file_name || "-"}</dd></div>
|
||||||
|
<div><dt className="text-slate-500">Dateigröße</dt><dd className="font-medium">{fileSize(document.file_size)}</dd></div>
|
||||||
|
<div><dt className="text-slate-500">SHA-256</dt><dd className="break-all font-medium">{document.checksum_sha256 || "-"}</dd></div>
|
||||||
|
<div><dt className="text-slate-500">Paperless-ID</dt><dd className="font-medium">{document.paperless_document_id || "-"}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div className="mt-4"><TagList tags={document.tags} /></div>
|
||||||
|
{document.description && <p className="mt-4 whitespace-pre-wrap text-sm text-slate-700">{document.description}</p>}
|
||||||
|
</DetailSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
frontend/athena/app/knowledge/documents/page.tsx
Normal file
184
frontend/athena/app/knowledge/documents/page.tsx
Normal file
|
|
@ -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<KnowledgeDocument[]>([]);
|
||||||
|
const [manufacturers, setManufacturers] = useState<KnowledgeManufacturer[]>([]);
|
||||||
|
const [devices, setDevices] = useState<KnowledgeDevice[]>([]);
|
||||||
|
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<DocumentType | "all">("all");
|
||||||
|
const [sortKey, setSortKey] = useState("created_at");
|
||||||
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<KnowledgeDocument | null>(null);
|
||||||
|
const [deleteItem, setDeleteItem] = useState<KnowledgeDocument | null>(null);
|
||||||
|
|
||||||
|
const loadItems = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [manufacturerResponse, deviceResponse, documentResponse] = await Promise.all([
|
||||||
|
api.get<KnowledgeManufacturer[]>("/knowledge/manufacturers?limit=100"),
|
||||||
|
api.get<KnowledgeDevice[]>("/knowledge/devices?limit=100"),
|
||||||
|
api.get<KnowledgeDocument[]>("/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<KnowledgeDocument>[] = [
|
||||||
|
{ key: "title", label: "Titel", sortable: true, render: (item) => <span className="font-medium">{item.title}</span> },
|
||||||
|
{ 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) => <DocumentTypeBadge type={item.document_type} /> },
|
||||||
|
{ 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) => <TagList tags={item.tags} /> },
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "Aktionen",
|
||||||
|
className: "px-4 py-3 text-right",
|
||||||
|
render: (item) => (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Link href={`/knowledge/documents/${item.id}`} className={buttonVariants({ variant: "ghost", size: "icon-sm" })} title="Details"><Eye size={16} /></Link>
|
||||||
|
{item.file_name && <Link href={`/api/knowledge/documents/${item.id}/download`} className={buttonVariants({ variant: "ghost", size: "icon-sm" })} title="Download"><Download size={16} /></Link>}
|
||||||
|
<Button variant="ghost" size="icon-sm" title="Bearbeiten" onClick={() => { setSelected(item); setFormError(""); setFormOpen(true); }}><Edit size={16} /></Button>
|
||||||
|
<Button variant="destructive" size="icon-sm" title="Löschen" onClick={() => setDeleteItem(item)}><Trash2 size={16} /></Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div><h1 className="text-3xl font-bold text-slate-950">Dokumente</h1><p className="mt-1 text-sm text-slate-500">{rows.length} Unterlagen</p></div>
|
||||||
|
<Button onClick={() => { setSelected(null); setFormError(""); setFormOpen(true); }} disabled={manufacturers.length === 0}><Plus size={16} />Upload</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 lg:flex-row">
|
||||||
|
<SearchInput value={search} onChange={setSearch} placeholder="Dokumente suchen" />
|
||||||
|
<select className="h-8 rounded-lg border px-2 text-sm" value={manufacturerFilter} onChange={(event) => setManufacturerFilter(event.target.value)}>
|
||||||
|
<option value="all">Alle Hersteller</option>
|
||||||
|
{manufacturers.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select className="h-8 rounded-lg border px-2 text-sm" value={typeFilter} onChange={(event) => setTypeFilter(event.target.value as DocumentType | "all")}>
|
||||||
|
<option value="all">Alle Typen</option>
|
||||||
|
<option value="manual">Bedienungsanleitung</option>
|
||||||
|
<option value="service_manual">Service Manual</option>
|
||||||
|
<option value="schematic">Schaltplan</option>
|
||||||
|
<option value="alignment">Abgleich</option>
|
||||||
|
<option value="parts_list">Ersatzteile</option>
|
||||||
|
<option value="firmware">Firmware</option>
|
||||||
|
<option value="datasheet">Datenblatt</option>
|
||||||
|
<option value="service_bulletin">Bulletin</option>
|
||||||
|
<option value="other">Sonstiges</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<DataTable columns={columns} rows={rows} rowKey={(item) => 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"); }} />
|
||||||
|
<DocumentUploadDialog open={formOpen} document={selected} manufacturers={manufacturers} devices={devices} pending={pending} error={formError} onOpenChange={setFormOpen} onSubmit={save} />
|
||||||
|
<ConfirmDialog open={Boolean(deleteItem)} title="Dokument löschen" description="Der Datenbankeintrag wird entfernt. Lokal gespeicherte Dateien bleiben für Nachvollziehbarkeit erhalten." pending={pending} onOpenChange={(open) => !open && setDeleteItem(null)} onConfirm={confirmDelete}>
|
||||||
|
{deleteItem && <p className="text-sm font-medium">{deleteItem.title}</p>}
|
||||||
|
</ConfirmDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
frontend/athena/app/knowledge/manufacturers/page.tsx
Normal file
132
frontend/athena/app/knowledge/manufacturers/page.tsx
Normal file
|
|
@ -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<KnowledgeManufacturer[]>([]);
|
||||||
|
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<KnowledgeManufacturer | null>(null);
|
||||||
|
const [deleteItem, setDeleteItem] = useState<KnowledgeManufacturer | null>(null);
|
||||||
|
|
||||||
|
const loadItems = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.get<KnowledgeManufacturer[]>("/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<KnowledgeManufacturer>[] = [
|
||||||
|
{ key: "name", label: "Name", sortable: true, render: (item) => <span className="font-medium">{item.name}</span> },
|
||||||
|
{ 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) => (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="icon-sm" title="Bearbeiten" onClick={() => { setSelected(item); setFormError(""); setFormOpen(true); }}><Edit size={16} /></Button>
|
||||||
|
<Button variant="destructive" size="icon-sm" title="Löschen" onClick={() => setDeleteItem(item)}><Trash2 size={16} /></Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div><h1 className="text-3xl font-bold text-slate-950">Hersteller</h1><p className="mt-1 text-sm text-slate-500">{rows.length} Einträge</p></div>
|
||||||
|
<Button onClick={() => { setSelected(null); setFormError(""); setFormOpen(true); }}><Plus size={16} />Hersteller</Button>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-white p-4"><SearchInput value={search} onChange={setSearch} placeholder="Hersteller suchen" /></div>
|
||||||
|
<DataTable columns={columns} rows={rows} rowKey={(item) => 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"); }} />
|
||||||
|
<ManufacturerFormDialog open={formOpen} manufacturer={selected} pending={pending} error={formError} onOpenChange={setFormOpen} onSubmit={save} />
|
||||||
|
<ConfirmDialog open={Boolean(deleteItem)} title="Hersteller löschen" description="Dieser Hersteller wird gelöscht, sofern keine abhängigen Dokumente bestehen." pending={pending} onOpenChange={(open) => !open && setDeleteItem(null)} onConfirm={confirmDelete}>
|
||||||
|
{deleteItem && <p className="text-sm font-medium">{deleteItem.name}</p>}
|
||||||
|
</ConfirmDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
frontend/athena/app/knowledge/notes/page.tsx
Normal file
152
frontend/athena/app/knowledge/notes/page.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||||
|
repair: "Reparaturhinweis",
|
||||||
|
known_fault: "Bekannter Fehler",
|
||||||
|
alignment: "Abgleichhinweis",
|
||||||
|
spare_part: "Ersatzteilhinweis",
|
||||||
|
general: "Allgemein",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function KnowledgeNotesPage() {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [notes, setNotes] = useState<KnowledgeNote[]>([]);
|
||||||
|
const [manufacturers, setManufacturers] = useState<KnowledgeManufacturer[]>([]);
|
||||||
|
const [devices, setDevices] = useState<KnowledgeDevice[]>([]);
|
||||||
|
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<KnowledgeNote | null>(null);
|
||||||
|
const [deleteItem, setDeleteItem] = useState<KnowledgeNote | null>(null);
|
||||||
|
|
||||||
|
const loadItems = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [manufacturerResponse, deviceResponse, noteResponse] = await Promise.all([
|
||||||
|
api.get<KnowledgeManufacturer[]>("/knowledge/manufacturers?limit=100"),
|
||||||
|
api.get<KnowledgeDevice[]>("/knowledge/devices?limit=100"),
|
||||||
|
api.get<KnowledgeNote[]>("/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<KnowledgeNote>[] = [
|
||||||
|
{ key: "title", label: "Titel", sortable: true, render: (item) => <span className="font-medium">{item.title}</span> },
|
||||||
|
{ 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) => <TagList tags={item.tags} /> },
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "Aktionen",
|
||||||
|
className: "px-4 py-3 text-right",
|
||||||
|
render: (item) => (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="icon-sm" title="Bearbeiten" onClick={() => { setSelected(item); setFormError(""); setFormOpen(true); }}><Edit size={16} /></Button>
|
||||||
|
<Button variant="destructive" size="icon-sm" title="Löschen" onClick={() => setDeleteItem(item)}><Trash2 size={16} /></Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div><h1 className="text-3xl font-bold text-slate-950">Knowledge Notes</h1><p className="mt-1 text-sm text-slate-500">{rows.length} Reparatur- und Servicehinweise</p></div>
|
||||||
|
<Button onClick={() => { setSelected(null); setFormError(""); setFormOpen(true); }}><Plus size={16} />Notiz</Button>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-white p-4"><SearchInput value={search} onChange={setSearch} placeholder="Notizen suchen" /></div>
|
||||||
|
<DataTable columns={columns} rows={rows} rowKey={(item) => 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"); }} />
|
||||||
|
<KnowledgeNoteFormDialog open={formOpen} note={selected} manufacturers={manufacturers} devices={devices} pending={pending} error={formError} onOpenChange={setFormOpen} onSubmit={save} />
|
||||||
|
<ConfirmDialog open={Boolean(deleteItem)} title="Notiz löschen" description="Diese Wissensnotiz wird dauerhaft gelöscht." pending={pending} onOpenChange={(open) => !open && setDeleteItem(null)} onConfirm={confirmDelete}>
|
||||||
|
{deleteItem && <p className="text-sm font-medium">{deleteItem.title}</p>}
|
||||||
|
</ConfirmDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
frontend/athena/app/knowledge/page.tsx
Normal file
121
frontend/athena/app/knowledge/page.tsx
Normal file
|
|
@ -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<KnowledgeManufacturer[]>([]);
|
||||||
|
const [devices, setDevices] = useState<KnowledgeDevice[]>([]);
|
||||||
|
const [documents, setDocuments] = useState<KnowledgeDocument[]>([]);
|
||||||
|
const [notes, setNotes] = useState<KnowledgeNote[]>([]);
|
||||||
|
const [results, setResults] = useState<KnowledgeSearchResult[]>([]);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
Promise.all([
|
||||||
|
api.get<KnowledgeManufacturer[]>("/knowledge/manufacturers?limit=100"),
|
||||||
|
api.get<KnowledgeDevice[]>("/knowledge/devices?limit=100"),
|
||||||
|
api.get<KnowledgeDocument[]>("/knowledge/documents?limit=10"),
|
||||||
|
api.get<KnowledgeNote[]>("/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<KnowledgeSearchResult[]>(`/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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-950">Wissensdatenbank</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">Serviceunterlagen, Modelle, Schaltpläne und Reparaturwissen</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link className={buttonVariants({ variant: "outline" })} href="/knowledge/manufacturers"><Factory size={16} />Hersteller</Link>
|
||||||
|
<Link className={buttonVariants({ variant: "outline" })} href="/knowledge/devices"><Cpu size={16} />Geräte</Link>
|
||||||
|
<Link className={buttonVariants({ variant: "outline" })} href="/knowledge/documents"><FileText size={16} />Dokumente</Link>
|
||||||
|
<Link className={buttonVariants({ variant: "outline" })} href="/knowledge/notes"><StickyNote size={16} />Notizen</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
<SummaryCard label="Hersteller" value={manufacturers.length} />
|
||||||
|
<SummaryCard label="Geräte" value={devices.length} />
|
||||||
|
<SummaryCard label="Dokumente" value={documents.length} />
|
||||||
|
<SummaryCard label="Notizen" value={notes.length} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="rounded-lg border bg-white p-4">
|
||||||
|
<SearchInput value={query} onChange={setQuery} placeholder="Hersteller, Modell, Dokument oder Notiz suchen" />
|
||||||
|
{results.length > 0 && (
|
||||||
|
<div className="mt-4 divide-y rounded-lg border">
|
||||||
|
{results.map((result) => (
|
||||||
|
<Link key={`${result.entity_type}-${result.id}`} href={result.href} className="flex items-center gap-3 p-3 hover:bg-slate-50">
|
||||||
|
<BookOpen size={16} className="text-slate-500" />
|
||||||
|
<span className="font-medium text-slate-950">{result.title}</span>
|
||||||
|
<span className="text-sm text-slate-500">{result.subtitle}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<section className="rounded-lg border bg-white p-5">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-950">Letzte Dokumente</h2>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{latestDocuments.map((document) => (
|
||||||
|
<Link key={document.id} href={`/knowledge/documents/${document.id}`} className="block rounded-lg border p-3 hover:bg-slate-50">
|
||||||
|
<p className="font-medium">{document.title}</p>
|
||||||
|
<p className="text-sm text-slate-500">{document.manufacturer.name}{document.device ? ` · ${document.device.name}` : ""}</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{latestDocuments.length === 0 && <p className="text-sm text-slate-500">Noch keine Dokumente vorhanden.</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="rounded-lg border bg-white p-5">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-950">Letzte Notizen</h2>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{latestNotes.map((note) => (
|
||||||
|
<Link key={note.id} href="/knowledge/notes" className="block rounded-lg border p-3 hover:bg-slate-50">
|
||||||
|
<p className="font-medium">{note.title}</p>
|
||||||
|
<p className="line-clamp-2 text-sm text-slate-500">{note.content}</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{latestNotes.length === 0 && <p className="text-sm text-slate-500">Noch keine Notizen vorhanden.</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
|
BookOpen,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
FileText,
|
FileText,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
|
@ -49,6 +50,12 @@ const menu = [
|
||||||
href: "/customers",
|
href: "/customers",
|
||||||
permission: "customers.read",
|
permission: "customers.read",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: BookOpen,
|
||||||
|
name: "Wissensdatenbank",
|
||||||
|
href: "/knowledge",
|
||||||
|
permission: "knowledge.read",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: Wrench,
|
icon: Wrench,
|
||||||
name: "Reparaturen",
|
name: "Reparaturen",
|
||||||
|
|
|
||||||
25
frontend/athena/components/knowledge/DocumentTypeBadge.tsx
Normal file
25
frontend/athena/components/knowledge/DocumentTypeBadge.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import type { DocumentType } from "@/types/knowledge";
|
||||||
|
|
||||||
|
const labels: Record<DocumentType, string> = {
|
||||||
|
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 (
|
||||||
|
<span className="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold text-slate-700">
|
||||||
|
{documentTypeLabel(type)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
325
frontend/athena/components/knowledge/KnowledgeForms.tsx
Normal file
325
frontend/athena/components/knowledge/KnowledgeForms.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>{manufacturer ? "Hersteller bearbeiten" : "Hersteller erstellen"}</DialogTitle></DialogHeader>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className={fieldClass()}><Label>Name</Label><Input value={name} onChange={(event) => setName(event.target.value)} /></div>
|
||||||
|
<div className={fieldClass()}><Label>Website</Label><Input value={website} onChange={(event) => setWebsite(event.target.value)} /></div>
|
||||||
|
<div className={fieldClass()}><Label>Notizen</Label><textarea className="min-h-24 rounded-lg border border-input p-2 text-sm" value={notes} onChange={(event) => setNotes(event.target.value)} /></div>
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
|
||||||
|
<Button onClick={() => onSubmit({ name, website: website || null, notes })} disabled={pending}>Speichern</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceFormDialog({
|
||||||
|
open,
|
||||||
|
device,
|
||||||
|
manufacturers,
|
||||||
|
pending,
|
||||||
|
error,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
device: KnowledgeDevice | null;
|
||||||
|
manufacturers: KnowledgeManufacturer[];
|
||||||
|
pending: boolean;
|
||||||
|
error: string;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (payload: DevicePayload) => void;
|
||||||
|
}) {
|
||||||
|
const [payload, setPayload] = useState<DevicePayload>({
|
||||||
|
manufacturer_id: 0,
|
||||||
|
name: "",
|
||||||
|
model_number: "",
|
||||||
|
device_type: "",
|
||||||
|
frequency_range: "",
|
||||||
|
production_year_from: null,
|
||||||
|
production_year_to: null,
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setPayload(device ? {
|
||||||
|
manufacturer_id: device.manufacturer_id,
|
||||||
|
name: device.name,
|
||||||
|
model_number: device.model_number,
|
||||||
|
device_type: device.device_type,
|
||||||
|
frequency_range: device.frequency_range,
|
||||||
|
production_year_from: device.production_year_from,
|
||||||
|
production_year_to: device.production_year_to,
|
||||||
|
notes: device.notes,
|
||||||
|
} : {
|
||||||
|
manufacturer_id: manufacturers[0]?.id ?? 0,
|
||||||
|
name: "",
|
||||||
|
model_number: "",
|
||||||
|
device_type: "",
|
||||||
|
frequency_range: "",
|
||||||
|
production_year_from: null,
|
||||||
|
production_year_to: null,
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [device, manufacturers, open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader><DialogTitle>{device ? "Gerät bearbeiten" : "Gerät erstellen"}</DialogTitle></DialogHeader>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className={fieldClass()}><Label>Hersteller</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.manufacturer_id} onChange={(event) => setPayload({ ...payload, manufacturer_id: Number(event.target.value) })}>{manufacturers.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></div>
|
||||||
|
<div className={fieldClass()}><Label>Name</Label><Input value={payload.name} onChange={(event) => setPayload({ ...payload, name: event.target.value })} /></div>
|
||||||
|
<div className={fieldClass()}><Label>Modellnummer</Label><Input value={payload.model_number} onChange={(event) => setPayload({ ...payload, model_number: event.target.value })} /></div>
|
||||||
|
<div className={fieldClass()}><Label>Gerätetyp</Label><Input value={payload.device_type} onChange={(event) => setPayload({ ...payload, device_type: event.target.value })} /></div>
|
||||||
|
<div className={fieldClass()}><Label>Frequenzbereich</Label><Input value={payload.frequency_range} onChange={(event) => setPayload({ ...payload, frequency_range: event.target.value })} /></div>
|
||||||
|
<div className={fieldClass()}><Label>Baujahr von</Label><Input type="number" value={payload.production_year_from ?? ""} onChange={(event) => setPayload({ ...payload, production_year_from: event.target.value ? Number(event.target.value) : null })} /></div>
|
||||||
|
<div className={fieldClass()}><Label>Baujahr bis</Label><Input type="number" value={payload.production_year_to ?? ""} onChange={(event) => setPayload({ ...payload, production_year_to: event.target.value ? Number(event.target.value) : null })} /></div>
|
||||||
|
<div className="grid gap-2 sm:col-span-2"><Label>Notizen</Label><textarea className="min-h-24 rounded-lg border border-input p-2 text-sm" value={payload.notes} onChange={(event) => setPayload({ ...payload, notes: event.target.value })} /></div>
|
||||||
|
{error && <p className="text-sm text-red-600 sm:col-span-2">{error}</p>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
|
||||||
|
<Button onClick={() => onSubmit(payload)} disabled={pending || !payload.manufacturer_id}>Speichern</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentUploadDialog({
|
||||||
|
open,
|
||||||
|
document,
|
||||||
|
manufacturers,
|
||||||
|
devices,
|
||||||
|
pending,
|
||||||
|
error,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
document: KnowledgeDocument | null;
|
||||||
|
manufacturers: KnowledgeManufacturer[];
|
||||||
|
devices: KnowledgeDevice[];
|
||||||
|
pending: boolean;
|
||||||
|
error: string;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (payload: DocumentPayload, file: File | null) => void;
|
||||||
|
}) {
|
||||||
|
const [payload, setPayload] = useState<DocumentPayload>({
|
||||||
|
manufacturer_id: 0,
|
||||||
|
device_id: null,
|
||||||
|
title: "",
|
||||||
|
document_type: "service_manual",
|
||||||
|
language: "de",
|
||||||
|
external_url: null,
|
||||||
|
paperless_document_id: "",
|
||||||
|
description: "",
|
||||||
|
tags: [],
|
||||||
|
});
|
||||||
|
const [tags, setTags] = useState("");
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setPayload(document ? {
|
||||||
|
manufacturer_id: document.manufacturer_id,
|
||||||
|
device_id: document.device_id,
|
||||||
|
title: document.title,
|
||||||
|
document_type: document.document_type,
|
||||||
|
language: document.language,
|
||||||
|
external_url: document.external_url || null,
|
||||||
|
paperless_document_id: document.paperless_document_id,
|
||||||
|
description: document.description,
|
||||||
|
tags: document.tags,
|
||||||
|
} : {
|
||||||
|
manufacturer_id: manufacturers[0]?.id ?? 0,
|
||||||
|
device_id: null,
|
||||||
|
title: "",
|
||||||
|
document_type: "service_manual",
|
||||||
|
language: "de",
|
||||||
|
external_url: null,
|
||||||
|
paperless_document_id: "",
|
||||||
|
description: "",
|
||||||
|
tags: [],
|
||||||
|
});
|
||||||
|
setTags(document ? tagsToText(document.tags) : "");
|
||||||
|
setFile(null);
|
||||||
|
});
|
||||||
|
}, [document, manufacturers, open]);
|
||||||
|
|
||||||
|
const filteredDevices = devices.filter((device) => device.manufacturer_id === payload.manufacturer_id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-3xl">
|
||||||
|
<DialogHeader><DialogTitle>{document ? "Dokument bearbeiten" : "Dokument hochladen"}</DialogTitle></DialogHeader>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className={fieldClass()}><Label>Hersteller</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.manufacturer_id} onChange={(event) => setPayload({ ...payload, manufacturer_id: Number(event.target.value), device_id: null })}>{manufacturers.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></div>
|
||||||
|
<div className={fieldClass()}><Label>Gerät</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.device_id ?? ""} onChange={(event) => setPayload({ ...payload, device_id: event.target.value ? Number(event.target.value) : null })}><option value="">Kein Gerät</option>{filteredDevices.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></div>
|
||||||
|
<div className={fieldClass()}><Label>Titel</Label><Input value={payload.title} onChange={(event) => setPayload({ ...payload, title: event.target.value })} /></div>
|
||||||
|
<div className={fieldClass()}><Label>Typ</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.document_type} onChange={(event) => setPayload({ ...payload, document_type: event.target.value as DocumentType })}>{documentTypes.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></div>
|
||||||
|
<div className={fieldClass()}><Label>Sprache</Label><Input value={payload.language} onChange={(event) => setPayload({ ...payload, language: event.target.value })} /></div>
|
||||||
|
<div className={fieldClass()}><Label>Paperless-ID</Label><Input value={payload.paperless_document_id} onChange={(event) => setPayload({ ...payload, paperless_document_id: event.target.value })} /></div>
|
||||||
|
<div className={fieldClass()}><Label>Externe URL</Label><Input value={payload.external_url ?? ""} onChange={(event) => setPayload({ ...payload, external_url: event.target.value || null })} /></div>
|
||||||
|
{!document && <div className={fieldClass()}><Label>Datei</Label><input type="file" className="h-9 rounded-lg border px-2 py-1 text-sm" onChange={(event) => setFile(event.target.files?.[0] ?? null)} /></div>}
|
||||||
|
<div className={fieldClass()}><Label>Tags</Label><Input value={tags} onChange={(event) => setTags(event.target.value)} placeholder="cb-funk, schaltplan" /></div>
|
||||||
|
<div className="grid gap-2 sm:col-span-2"><Label>Beschreibung</Label><textarea className="min-h-24 rounded-lg border border-input p-2 text-sm" value={payload.description} onChange={(event) => setPayload({ ...payload, description: event.target.value })} /></div>
|
||||||
|
{error && <p className="text-sm text-red-600 sm:col-span-2">{error}</p>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
|
||||||
|
<Button onClick={() => onSubmit({ ...payload, tags: textToTags(tags) }, file)} disabled={pending || !payload.manufacturer_id || (!document && !file)}>Speichern</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KnowledgeNoteFormDialog({
|
||||||
|
open,
|
||||||
|
note,
|
||||||
|
manufacturers,
|
||||||
|
devices,
|
||||||
|
pending,
|
||||||
|
error,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
note: KnowledgeNote | null;
|
||||||
|
manufacturers: KnowledgeManufacturer[];
|
||||||
|
devices: KnowledgeDevice[];
|
||||||
|
pending: boolean;
|
||||||
|
error: string;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (payload: NotePayload) => void;
|
||||||
|
}) {
|
||||||
|
const [payload, setPayload] = useState<NotePayload>({ manufacturer_id: null, device_id: null, title: "", content: "", note_type: "general", severity: "", tags: [] });
|
||||||
|
const [tags, setTags] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setPayload(note ? {
|
||||||
|
manufacturer_id: note.manufacturer_id,
|
||||||
|
device_id: note.device_id,
|
||||||
|
title: note.title,
|
||||||
|
content: note.content,
|
||||||
|
note_type: note.note_type,
|
||||||
|
severity: note.severity,
|
||||||
|
tags: note.tags,
|
||||||
|
} : { manufacturer_id: null, device_id: null, title: "", content: "", note_type: "general", severity: "", tags: [] });
|
||||||
|
setTags(note ? tagsToText(note.tags) : "");
|
||||||
|
});
|
||||||
|
}, [note, open]);
|
||||||
|
|
||||||
|
const filteredDevices = payload.manufacturer_id ? devices.filter((device) => device.manufacturer_id === payload.manufacturer_id) : devices;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-3xl">
|
||||||
|
<DialogHeader><DialogTitle>{note ? "Notiz bearbeiten" : "Notiz erstellen"}</DialogTitle></DialogHeader>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className={fieldClass()}><Label>Hersteller</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.manufacturer_id ?? ""} onChange={(event) => setPayload({ ...payload, manufacturer_id: event.target.value ? Number(event.target.value) : null, device_id: null })}><option value="">Kein Hersteller</option>{manufacturers.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></div>
|
||||||
|
<div className={fieldClass()}><Label>Gerät</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.device_id ?? ""} onChange={(event) => setPayload({ ...payload, device_id: event.target.value ? Number(event.target.value) : null })}><option value="">Kein Gerät</option>{filteredDevices.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></div>
|
||||||
|
<div className={fieldClass()}><Label>Titel</Label><Input value={payload.title} onChange={(event) => setPayload({ ...payload, title: event.target.value })} /></div>
|
||||||
|
<div className={fieldClass()}><Label>Typ</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.note_type} onChange={(event) => setPayload({ ...payload, note_type: event.target.value as NoteType })}>{noteTypes.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></div>
|
||||||
|
<div className={fieldClass()}><Label>Schweregrad</Label><select className="h-9 rounded-lg border px-2 text-sm" value={payload.severity} onChange={(event) => setPayload({ ...payload, severity: event.target.value as NotePayload["severity"] })}><option value="">Keiner</option><option value="low">Niedrig</option><option value="medium">Mittel</option><option value="high">Hoch</option><option value="critical">Kritisch</option></select></div>
|
||||||
|
<div className={fieldClass()}><Label>Tags</Label><Input value={tags} onChange={(event) => setTags(event.target.value)} /></div>
|
||||||
|
<div className="grid gap-2 sm:col-span-2"><Label>Inhalt</Label><textarea className="min-h-40 rounded-lg border border-input p-2 text-sm" value={payload.content} onChange={(event) => setPayload({ ...payload, content: event.target.value })} /></div>
|
||||||
|
{error && <p className="text-sm text-red-600 sm:col-span-2">{error}</p>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>Abbrechen</Button>
|
||||||
|
<Button onClick={() => onSubmit({ ...payload, tags: textToTags(tags) })} disabled={pending}>Speichern</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/athena/components/knowledge/TagList.tsx
Normal file
15
frontend/athena/components/knowledge/TagList.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
export default function TagList({ tags }: { tags: string[] }) {
|
||||||
|
if (tags.length === 0) {
|
||||||
|
return <span className="text-slate-400">-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span key={tag} className="rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -57,3 +57,47 @@ export async function proxyHermesRequest(
|
||||||
|
|
||||||
return hermesJsonResponse(hermesResponse);
|
return hermesJsonResponse(hermesResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function proxyHermesStreamRequest(
|
||||||
|
request: NextRequest,
|
||||||
|
path: string,
|
||||||
|
) {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return unauthorizedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hermesUrl = getHermesUrl();
|
||||||
|
|
||||||
|
if (!hermesUrl) {
|
||||||
|
return upstreamConfigurationErrorResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
let hermesResponse: Response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
hermesResponse = await fetch(`${hermesUrl}${path}`, {
|
||||||
|
method: request.method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
Accept: request.headers.get("accept") ?? "*/*",
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return upstreamUnavailableResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hermesResponse.ok) {
|
||||||
|
return hermesJsonResponse(hermesResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(hermesResponse.body, {
|
||||||
|
status: hermesResponse.status,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": hermesResponse.headers.get("content-type") ?? "application/octet-stream",
|
||||||
|
"Content-Disposition": hermesResponse.headers.get("content-disposition") ?? "attachment",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
79
frontend/athena/types/knowledge.ts
Normal file
79
frontend/athena/types/knowledge.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
export type DocumentType = "manual" | "service_manual" | "schematic" | "alignment" | "parts_list" | "firmware" | "datasheet" | "service_bulletin" | "other";
|
||||||
|
export type NoteType = "repair" | "known_fault" | "alignment" | "spare_part" | "general";
|
||||||
|
|
||||||
|
export interface KnowledgeManufacturer {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
website: string;
|
||||||
|
notes: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeDevice {
|
||||||
|
id: number;
|
||||||
|
manufacturer_id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
model_number: string;
|
||||||
|
device_type: string;
|
||||||
|
frequency_range: string;
|
||||||
|
production_year_from: number | null;
|
||||||
|
production_year_to: number | null;
|
||||||
|
notes: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
manufacturer: KnowledgeManufacturer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeDocument {
|
||||||
|
id: number;
|
||||||
|
manufacturer_id: number;
|
||||||
|
device_id: number | null;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
document_type: DocumentType;
|
||||||
|
language: string;
|
||||||
|
file_name: string;
|
||||||
|
file_path: string;
|
||||||
|
mime_type: string;
|
||||||
|
file_size: number;
|
||||||
|
checksum_sha256: string;
|
||||||
|
external_url: string;
|
||||||
|
paperless_document_id: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
manufacturer: KnowledgeManufacturer;
|
||||||
|
device: KnowledgeDevice | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeNote {
|
||||||
|
id: number;
|
||||||
|
manufacturer_id: number | null;
|
||||||
|
device_id: number | null;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
note_type: NoteType;
|
||||||
|
severity: "" | "low" | "medium" | "high" | "critical";
|
||||||
|
tags: string[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
manufacturer: KnowledgeManufacturer | null;
|
||||||
|
device: KnowledgeDevice | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeSearchResult {
|
||||||
|
entity_type: string;
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ManufacturerPayload = Pick<KnowledgeManufacturer, "name" | "notes"> & { website?: string | null };
|
||||||
|
export type DevicePayload = Omit<KnowledgeDevice, "id" | "slug" | "created_at" | "updated_at" | "manufacturer">;
|
||||||
|
export type DocumentPayload = Pick<KnowledgeDocument, "manufacturer_id" | "device_id" | "title" | "document_type" | "language" | "paperless_document_id" | "description" | "tags"> & { external_url?: string | null };
|
||||||
|
export type NotePayload = Pick<KnowledgeNote, "manufacturer_id" | "device_id" | "title" | "content" | "note_type" | "severity" | "tags">;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue