From c816e9869d0cd5a13e3de96bb88a93433522bfad Mon Sep 17 00:00:00 2001 From: Schubert Ferenc Date: Fri, 3 Jul 2026 00:04:44 +0200 Subject: [PATCH 01/23] feat(platform): add audit logs and activity feed --- ARCHITECTURE.md | 86 +++++ CONTRIBUTING.md | 18 ++ ROADMAP.md | 66 ++++ .../versions/a5e4b7c9d012_add_audit_logs.py | 86 +++++ backend/hermes/app/api/audit.py | 80 +++++ backend/hermes/app/api/auth.py | 29 ++ backend/hermes/app/api/customers.py | 97 +++++- backend/hermes/app/api/roles.py | 61 +++- backend/hermes/app/api/users.py | 62 +++- backend/hermes/app/core/config.py | 1 + backend/hermes/app/core/logging.py | 14 + backend/hermes/app/db/database.py | 1 + backend/hermes/app/main.py | 58 +++- backend/hermes/app/models/audit.py | 28 ++ backend/hermes/app/rbac/defaults.py | 1 + .../app/repositories/audit_repository.py | 104 +++++++ backend/hermes/app/schemas/api_response.py | 16 + backend/hermes/app/schemas/audit.py | 39 +++ backend/hermes/app/services/audit_service.py | 148 +++++++++ backend/hermes/docker-compose.yml | 1 + docker-compose.yml | 5 +- .../athena/app/api/activity-feed/route.ts | 7 + frontend/athena/app/api/audit-logs/route.ts | 7 + frontend/athena/app/audit-logs/page.tsx | 294 ++++++++++++++++++ frontend/athena/app/customers/[id]/page.tsx | 24 +- frontend/athena/app/customers/page.tsx | 13 +- frontend/athena/app/dashboard/page.tsx | 76 ++++- frontend/athena/app/layout.tsx | 23 +- frontend/athena/app/roles/page.tsx | 13 +- frontend/athena/app/users/[id]/page.tsx | 12 +- frontend/athena/app/users/page.tsx | 13 +- frontend/athena/components/Sidebar.tsx | 7 + .../components/common/ToastProvider.tsx | 107 +++++++ frontend/athena/types/audit.ts | 38 +++ 34 files changed, 1592 insertions(+), 43 deletions(-) create mode 100644 ROADMAP.md create mode 100644 backend/hermes/alembic/versions/a5e4b7c9d012_add_audit_logs.py create mode 100644 backend/hermes/app/api/audit.py create mode 100644 backend/hermes/app/core/logging.py create mode 100644 backend/hermes/app/models/audit.py create mode 100644 backend/hermes/app/repositories/audit_repository.py create mode 100644 backend/hermes/app/schemas/api_response.py create mode 100644 backend/hermes/app/schemas/audit.py create mode 100644 backend/hermes/app/services/audit_service.py create mode 100644 frontend/athena/app/api/activity-feed/route.ts create mode 100644 frontend/athena/app/api/audit-logs/route.ts create mode 100644 frontend/athena/app/audit-logs/page.tsx create mode 100644 frontend/athena/components/common/ToastProvider.tsx create mode 100644 frontend/athena/types/audit.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2257b68..870d764 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -58,6 +58,8 @@ Sie verbindet: - das konfigurierte Docker-Netzwerk - die notwendigen Runtime-Umgebungsvariablen +Im gemeinsamen Compose-Stack wird Athena am Host veroeffentlicht. Hermes wird nur intern im Docker-Netzwerk exponiert und von Athena ueber `HERMES_INTERNAL_URL` erreicht. + ## BFF-Architektur Olympus nutzt eine Backend-for-Frontend-Architektur. @@ -172,6 +174,14 @@ Beispiele: Nicht vorhandene Module wie Aufgaben, Tickets oder Projekte werden nicht mit Fake-Daten gefuellt. Stattdessen liefert das Dashboard leere Widgets mit klarer Meldung. +### Activity Feed + +Der Activity Feed ist Teil der v0.5.0-Qualitaetsplattform. + +Athena ruft `GET /api/activity-feed` auf. Die BFF-Route ruft serverseitig Hermes `GET /activity-feed` auf. + +Der Feed basiert auf persistenten Audit Logs und zeigt die letzten relevanten Aktivitaeten. Hermes filtert die Eintraege anhand der Berechtigungen des aktuellen Benutzers. Ein Benutzer sieht dadurch nur Aktivitaeten zu Bereichen, fuer die er Leserechte besitzt. + ## RBAC: Rollen und Berechtigungen Olympus verwendet ein serverseitiges RBAC-System als Grundlage fuer alle CRM-Module. @@ -250,6 +260,10 @@ Dashboard und System: - `system.settings.read` - `system.settings.update` +Audit: + +- `audit_logs.read` + ### Berechtigungspruefung Zentrale Hermes-Dependencies: @@ -374,12 +388,80 @@ Kontakte und Adressen gehoeren fachlich zum Kunden und werden aktuell ueber `cus Ein Kunden-Delete entfernt aktuell den Kunden inklusive Adressen und Ansprechpartnern. Fuer spaetere Projekte oder Tickets ist ein fachlicher Loeschschutz vorzubereiten, sobald diese Module existieren. +## Audit Logs + +Audit Logs sind die zentrale Nachvollziehbarkeitsschicht fuer v0.5.0. + +Hermes persistiert relevante Ereignisse in der Tabelle `audit_logs`. + +Erfasste Informationen: + +- handelnder Benutzer +- Aktion, z. B. `users.update` oder `customers.delete` +- Objekttyp und Objekt-ID +- lesbares Objektlabel +- IP-Adresse und User-Agent +- Vorher-/Nachher-Daten fuer Aenderungen +- Metadaten fuer Kontextinformationen +- Erstellungszeitpunkt + +Sensible Felder wie Passwoerter, Tokens und Secrets werden vor dem Schreiben maskiert. + +Audit Logs werden aktuell fuer diese Bereiche geschrieben: + +- Login erfolgreich/fehlgeschlagen +- Benutzer erstellen, bearbeiten, loeschen, Passwort aendern +- Rollen erstellen, bearbeiten, loeschen, Berechtigungen aendern +- Kunden erstellen, bearbeiten, loeschen +- Ansprechpartner erstellen, bearbeiten, loeschen + +Hermes-Endpunkte: + +- `GET /audit-logs` +- `GET /activity-feed` + +Athena-BFF-Routen: + +- `GET /api/audit-logs` +- `GET /api/activity-feed` + +Die Audit-Log-UI liegt unter `frontend/athena/app/audit-logs` und ist ueber `audit_logs.read` sichtbar. + +## API-Response-Standard + +Neue Plattform-Endpunkte verwenden eine einheitliche Response-Huelle: + +```json +{ + "success": true, + "data": {}, + "message": "Daten geladen" +} +``` + +Fehlerantworten aus Hermes verwenden ein konsistentes Format mit `success`, `message`, `error_code` und `details`. Fuer bestehende Athena-Fehlerbehandlung bleibt `detail` zusaetzlich erhalten. + +## Logging + +Hermes konfiguriert Logging zentral ueber `backend/hermes/app/core/logging.py`. + +HTTP-Requests werden strukturiert mit Methode, Pfad, Statuscode und Dauer geloggt. Fachliche Ereignisse bleiben in den API-Modulen als strukturierte Logeintraege erhalten. + +Das Runtime-Loglevel wird ueber `LOG_LEVEL` gesteuert. + +## Frontend-Feedback + +Athena nutzt einen globalen Toast-Provider fuer konsistente Erfolgs- und Fehlermeldungen. + +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. + ## Verzeichnisstruktur ```text backend/hermes app/ api/ + audit/ core/ db/ models/ @@ -393,6 +475,7 @@ backend/hermes frontend/athena app/ api/ + audit-logs/ customers/ roles/ users/ @@ -422,6 +505,9 @@ docker-compose.yml `ACCESS_TOKEN_EXPIRE_MINUTES` : Ablaufzeit fuer Access Tokens in Minuten. +`LOG_LEVEL` +: Runtime-Loglevel fuer Hermes, z. B. `INFO`, `WARNING` oder `ERROR`. + ### Athena `HERMES_INTERNAL_URL` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5027b0d..f4e323d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,6 +64,8 @@ Eine Aenderung gilt erst als fertig, wenn diese Punkte erfuellt sind: - `npx next build --webpack` erfolgreich in `frontend/athena` - `python3 -m compileall backend/hermes/app` erfolgreich im Projektroot - Alembic-Migrationen geprueft, falls Datenbankschema betroffen ist +- `uv run alembic history` erfolgreich in `backend/hermes` +- `uv run alembic upgrade head` erfolgreich in `backend/hermes`, wenn Migrationen betroffen sind - Docker Compose startet - keine `.env` im Git - keine `node_modules` im Git @@ -85,6 +87,12 @@ npx next build --webpack python3 -m compileall backend/hermes/app ``` +```bash +cd backend/hermes +uv run alembic history +uv run alembic upgrade head +``` + ## Coding Standards ### Frontend @@ -94,6 +102,7 @@ python3 -m compileall backend/hermes/app - Browser-API-Zugriffe nur ueber Athena `/api/...`. - Wiederverwendbare Komponenten bevorzugen. - UI-Zustaende immer abbilden: Loading, Error, Empty State. +- Erfolg und Fehler in mutierenden CRUD-Flows ueber den Toast-Provider melden. - Keine Browser-Dialoge wie `alert()` oder `confirm()`. - Keine Tokens in Browser-JavaScript speichern. @@ -107,6 +116,8 @@ python3 -m compileall backend/hermes/app - Nicht gefundene Ressourcen als `404 Not Found`. - Authentifizierung serverseitig pruefen. - Berechtigungen mit `require_permission`, `require_any_permission` oder `require_all_permissions` pruefen. +- Mutierende Kernaktionen mit Audit Logs erfassen, sofern fachlich relevant. +- Sensible Felder vor Persistenz in Logs oder Audit-Daten maskieren. ### Allgemein @@ -130,6 +141,8 @@ Diese Regeln sind verbindlich: - Keine Cross-Origin-Cookie-Loesungen. - RBAC wird serverseitig in Hermes durchgesetzt. - Frontend-Permissions dienen nur der UI und ersetzen keine Backend-Pruefung. +- Audit Logs werden serverseitig in Hermes geschrieben. +- Neue Plattform-Endpunkte sollen das einheitliche API-Response-Format nutzen. Wenn eine Aufgabe diese Regeln zu verletzen scheint, muss zuerst die Architekturentscheidung geklaert werden. @@ -151,6 +164,7 @@ Verbindliche Wiederverwendung: - `ConfirmDialog` fuer destruktive Aktionen - `SearchInput` fuer Suchfelder - `StatusBadge` oder aehnliche Badge-Komponenten fuer Statusanzeigen +- `ToastProvider` fuer Erfolg und Fehler bei mutierenden Aktionen - `api` Client aus `frontend/athena/lib/api.ts` - Athena API-Routes als BFF-Schicht - konsistente Fehlerbehandlung @@ -177,6 +191,7 @@ Ein neues CRM-Modul soll sich am Kundenmodul orientieren: - Frontend-Seiten unter `frontend/athena/app/` - wiederverwendbare UI-Komponenten unter `frontend/athena/components` - RBAC-Permissions vor der UI-Integration definieren +- Audit-Log-Ereignisse fuer relevante Create/Update/Delete-Aktionen definieren - keine Fake-Daten fuer noch nicht existierende Unterbereiche Dashboard-Widgets fuer noch nicht implementierte Module muessen Empty States anzeigen statt hart codierter Beispieldaten. @@ -228,6 +243,9 @@ Vor Merge pruefen: - keine neuen CORS- oder Cookie-Workarounds - Permission-Pruefung fuer neue Endpunkte vorhanden - Permission-UI nur als Komfort, nicht als Sicherheitsgrenze +- Audit Logs fuer relevante Aenderungen vorhanden +- API-Fehlerantworten konsistent +- Toasts fuer mutierende UI-Aktionen vorhanden ## Migrationsregeln diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..764f49c --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,66 @@ +# Olympus CRM Roadmap + +Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Architekturregeln aus `ARCHITECTURE.md` und Entwicklungsregeln aus `CONTRIBUTING.md` bleiben fuer alle Versionen verbindlich. + +## v0.2.0 - Auth und Benutzerverwaltung + +- Serverseitige Authentifizierung ueber Athena BFF und Hermes JWT +- HttpOnly-Cookie in Athena +- Benutzerverwaltung mit CRUD +- Passwortaenderung +- Self-Delete-Schutz + +## v0.3.0 - RBAC + +- Rollenmodell +- Berechtigungen +- Serverseitige Permission-Pruefung in Hermes +- Permission-basierte Navigation und UI-Aktionen in Athena + +## v0.4.0 - Dashboard und Kundenmodul + +- Dashboard mit berechtigungsabhaengigen Kennzahlen +- Kundenverwaltung mit Adressen und Ansprechpartnern +- Kunden-BFF-Routen in Athena +- Keine Fake-Daten fuer nicht implementierte Module + +## v0.5.0 - Qualitaetsplattform + +- Persistente Audit Logs +- Activity Feed im Dashboard +- Audit-Log-UI +- Einheitliches Fehler- und Response-Format fuer neue Plattform-Endpunkte +- Zentrales Hermes-Logging mit `LOG_LEVEL` +- Globale Toasts fuer CRUD-Erfolg und Fehler +- Docker-Konfiguration fuer Logging-Level + +## v0.6.0 - Projektmodul, geplant + +- Projektstammdaten +- Projektstatus und Verantwortliche +- Verknuepfung mit Kunden +- RBAC-Permissions fuer Projekte +- Audit Logs fuer Projektaktionen + +## v0.7.0 - Tickets, geplant + +- Ticketverwaltung +- Status- und Prioritaetsmodell +- Zuordnung zu Kunden und Benutzern +- RBAC-Permissions fuer Tickets +- Audit Logs fuer Ticketaktionen + +## v0.8.0 - Kalender und Aufgaben, geplant + +- Aufgabenverwaltung +- Kalenderansicht +- Faelligkeiten und Verantwortlichkeiten +- Verknuepfung mit Kunden, Projekten und Tickets + +## v1.0.0 - Produktiver Beta-Release + +- Gehaertete Produktionskonfiguration +- Vollstaendige Migrationsstrategie +- Deployment-Dokumentation +- Betriebs- und Backup-Konzept +- Review aller Sicherheitsregeln diff --git a/backend/hermes/alembic/versions/a5e4b7c9d012_add_audit_logs.py b/backend/hermes/alembic/versions/a5e4b7c9d012_add_audit_logs.py new file mode 100644 index 0000000..11d3807 --- /dev/null +++ b/backend/hermes/alembic/versions/a5e4b7c9d012_add_audit_logs.py @@ -0,0 +1,86 @@ +"""add audit logs + +Revision ID: a5e4b7c9d012 +Revises: 9c1d8a2f6b44 +Create Date: 2026-07-02 15:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "a5e4b7c9d012" +down_revision: Union[str, Sequence[str], None] = "9c1d8a2f6b44" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "audit_logs", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("actor_user_id", sa.Integer(), nullable=True), + sa.Column("actor_username", sa.String(length=120), server_default="", nullable=False), + sa.Column("action", sa.String(length=120), nullable=False), + sa.Column("entity_type", sa.String(length=80), nullable=False), + sa.Column("entity_id", sa.Integer(), nullable=True), + sa.Column("entity_label", sa.String(length=255), server_default="", nullable=False), + sa.Column("ip_address", sa.String(length=80), server_default="", nullable=False), + sa.Column("user_agent", sa.Text(), server_default="", nullable=False), + sa.Column("before_data", sa.JSON(), nullable=True), + sa.Column("after_data", sa.JSON(), nullable=True), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_audit_logs_action"), "audit_logs", ["action"], unique=False) + op.create_index(op.f("ix_audit_logs_actor_user_id"), "audit_logs", ["actor_user_id"], unique=False) + op.create_index(op.f("ix_audit_logs_entity_id"), "audit_logs", ["entity_id"], unique=False) + op.create_index(op.f("ix_audit_logs_entity_type"), "audit_logs", ["entity_type"], unique=False) + op.create_index(op.f("ix_audit_logs_created_at"), "audit_logs", ["created_at"], unique=False) + + op.execute( + """ + INSERT INTO permissions (name, display_name, description, module) + SELECT 'audit_logs.read', 'Audit Logs lesen', 'Audit Logs anzeigen', 'audit_logs' + WHERE NOT EXISTS ( + SELECT 1 FROM permissions WHERE name = 'audit_logs.read' + ) + """ + ) + op.execute( + """ + INSERT INTO role_permissions (role_id, permission_id) + SELECT roles.id, permissions.id + FROM roles + JOIN permissions ON permissions.name = 'audit_logs.read' + WHERE roles.name = 'administrator' + AND NOT EXISTS ( + SELECT 1 + FROM role_permissions + WHERE role_permissions.role_id = roles.id + AND role_permissions.permission_id = permissions.id + ) + """ + ) + + +def downgrade() -> None: + op.execute( + """ + DELETE FROM role_permissions + USING permissions + WHERE role_permissions.permission_id = permissions.id + AND permissions.name = 'audit_logs.read' + """ + ) + op.execute("DELETE FROM permissions WHERE name = 'audit_logs.read'") + op.drop_index(op.f("ix_audit_logs_created_at"), table_name="audit_logs") + op.drop_index(op.f("ix_audit_logs_entity_type"), table_name="audit_logs") + op.drop_index(op.f("ix_audit_logs_entity_id"), table_name="audit_logs") + op.drop_index(op.f("ix_audit_logs_actor_user_id"), table_name="audit_logs") + op.drop_index(op.f("ix_audit_logs_action"), table_name="audit_logs") + op.drop_table("audit_logs") diff --git a/backend/hermes/app/api/audit.py b/backend/hermes/app/api/audit.py new file mode 100644 index 0000000..15d151c --- /dev/null +++ b/backend/hermes/app/api/audit.py @@ -0,0 +1,80 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.core.rbac import get_user_permission_names, require_permission +from app.db.database import get_db +from app.models.user import User +from app.repositories.audit_repository import AuditRepository +from app.schemas.api_response import ApiSuccess +from app.schemas.audit import ActivityFeedItem, AuditLogListResponse +from app.services.audit_service import audit_to_activity + +router = APIRouter(tags=["Audit"]) + + +@router.get("/audit-logs", response_model=ApiSuccess) +def get_audit_logs( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=25, ge=1, le=100), + entity_type: str | None = None, + actor_user_id: int | None = None, + action: str | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("audit_logs.read")), +): + items, total = AuditRepository.list( + db, + page=page, + page_size=page_size, + entity_type=entity_type, + actor_user_id=actor_user_id, + action=action, + date_from=date_from, + date_to=date_to, + ) + return ApiSuccess( + data=AuditLogListResponse( + items=items, + total=total, + page=page, + page_size=page_size, + ), + message="Audit Logs geladen", + ) + + +def can_read_activity(action: str, permissions: set[str]) -> bool: + if action.startswith("users."): + return "users.read" in permissions + if action.startswith("customers.") or action.startswith("customer_contacts."): + return "customers.read" in permissions + if action.startswith("roles."): + return "roles.read" in permissions + if action.startswith("audit_logs."): + return "audit_logs.read" in permissions + if action.startswith("auth."): + return "audit_logs.read" in permissions + return False + + +@router.get("/activity-feed", response_model=ApiSuccess) +def get_activity_feed( + limit: int = Query(default=10, ge=1, le=50), + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("dashboard.read")), +): + permissions = get_user_permission_names(current_user) + activities: list[ActivityFeedItem] = [] + + for audit_log in AuditRepository.latest(db, limit=100): + if not can_read_activity(audit_log.action, permissions): + continue + activities.append(ActivityFeedItem(**audit_to_activity(audit_log))) + if len(activities) >= limit: + break + + return ApiSuccess(data=activities, message="Aktivitäten geladen") diff --git a/backend/hermes/app/api/auth.py b/backend/hermes/app/api/auth.py index f325b87..0660f5d 100644 --- a/backend/hermes/app/api/auth.py +++ b/backend/hermes/app/api/auth.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session +from starlette.requests import Request from app.core.security import ( create_access_token, @@ -12,6 +13,7 @@ from app.models.user import User from app.repositories.user_repository import UserRepository from app.schemas.rbac import CurrentUserResponse from app.schemas.user import LoginRequest, LoginResponse +from app.services.audit_service import write_audit_log router = APIRouter( prefix="/auth", @@ -22,23 +24,50 @@ router = APIRouter( @router.post("/login", response_model=LoginResponse) def login( login: LoginRequest, + request: Request, db: Session = Depends(get_db), ): user = UserRepository.get_by_username(db, login.username) if not user: + write_audit_log( + db, + action="auth.login.failed", + entity_type="auth", + entity_label=login.username, + actor_username=login.username, + request=request, + ) raise HTTPException( status_code=401, detail="Ungültige Anmeldedaten", ) if not verify_password(login.password, user.password_hash): + write_audit_log( + db, + action="auth.login.failed", + entity_type="auth", + entity_label=login.username, + actor_user_id=user.id, + actor_username=user.username, + request=request, + ) raise HTTPException( status_code=401, detail="Ungültige Anmeldedaten", ) token = create_access_token(user.username) + write_audit_log( + db, + action="auth.login.success", + entity_type="auth", + entity_id=user.id, + entity_label=user.username, + actor=user, + request=request, + ) return { "access_token": token, diff --git a/backend/hermes/app/api/customers.py b/backend/hermes/app/api/customers.py index 3cc9339..d26e1be 100644 --- a/backend/hermes/app/api/customers.py +++ b/backend/hermes/app/api/customers.py @@ -2,6 +2,7 @@ import logging from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy.orm import Session +from starlette.requests import Request from app.core.rbac import require_permission from app.db.database import get_db @@ -16,6 +17,7 @@ from app.schemas.customer import ( CustomerResponse, CustomerUpdate, ) +from app.services.audit_service import sanitize, write_audit_log logger = logging.getLogger(__name__) @@ -94,42 +96,81 @@ def get_customer( ) def create_customer( customer: CustomerCreate, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("customers.create")), ): ensure_unique_customer_number(db, customer.customer_number) logger.info("customers.create", extra={"actor_user_id": current_user.id}) - return CustomerRepository.create(db, customer) + created_customer = CustomerRepository.create(db, customer) + write_audit_log( + db, + action="customers.create", + entity_type="customers", + entity_id=created_customer.id, + entity_label=created_customer.company_name, + actor=current_user, + request=request, + after_data=created_customer, + ) + return created_customer @router.put("/{customer_id}", response_model=CustomerResponse) def update_customer( customer_id: int, customer: CustomerUpdate, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("customers.update")), ): db_customer = get_customer_or_404(db, customer_id) + before_data = sanitize(db_customer) ensure_unique_customer_number(db, customer.customer_number, customer_id) logger.info( "customers.update", extra={"actor_user_id": current_user.id, "target_customer_id": customer_id}, ) - return CustomerRepository.update(db, db_customer, customer) + updated_customer = CustomerRepository.update(db, db_customer, customer) + write_audit_log( + db, + action="customers.update", + entity_type="customers", + entity_id=updated_customer.id, + entity_label=updated_customer.company_name, + actor=current_user, + request=request, + before_data=before_data, + after_data=updated_customer, + ) + return updated_customer @router.delete("/{customer_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_customer( customer_id: int, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("customers.delete")), ): db_customer = get_customer_or_404(db, customer_id) + before_data = sanitize(db_customer) + entity_label = db_customer.company_name logger.info( "customers.delete", extra={"actor_user_id": current_user.id, "target_customer_id": customer_id}, ) CustomerRepository.delete(db, db_customer) + write_audit_log( + db, + action="customers.delete", + entity_type="customers", + entity_id=customer_id, + entity_label=entity_label, + actor=current_user, + request=request, + before_data=before_data, + ) return Response(status_code=status.HTTP_204_NO_CONTENT) @@ -155,15 +196,28 @@ def get_customer_contacts( def create_customer_contact( customer_id: int, contact: CustomerContactCreate, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("customers.update")), ): - get_customer_or_404(db, customer_id) + customer = get_customer_or_404(db, customer_id) logger.info( "customers.contacts.create", extra={"actor_user_id": current_user.id, "target_customer_id": customer_id}, ) - return CustomerRepository.create_contact(db, customer_id, contact) + created_contact = CustomerRepository.create_contact(db, customer_id, contact) + write_audit_log( + db, + action="customer_contacts.create", + entity_type="customer_contacts", + entity_id=created_contact.id, + entity_label=f"{created_contact.first_name} {created_contact.last_name}".strip(), + actor=current_user, + request=request, + after_data=created_contact, + metadata={"customer_id": customer_id, "customer_label": customer.company_name}, + ) + return created_contact @router.put("/{customer_id}/contacts/{contact_id}", response_model=CustomerContactResponse) @@ -171,11 +225,13 @@ def update_customer_contact( customer_id: int, contact_id: int, contact: CustomerContactUpdate, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("customers.update")), ): - get_customer_or_404(db, customer_id) + customer = get_customer_or_404(db, customer_id) db_contact = get_contact_or_404(db, customer_id, contact_id) + before_data = sanitize(db_contact) logger.info( "customers.contacts.update", extra={ @@ -184,18 +240,34 @@ def update_customer_contact( "target_contact_id": contact_id, }, ) - return CustomerRepository.update_contact(db, db_contact, contact) + updated_contact = CustomerRepository.update_contact(db, db_contact, contact) + write_audit_log( + db, + action="customer_contacts.update", + entity_type="customer_contacts", + entity_id=updated_contact.id, + entity_label=f"{updated_contact.first_name} {updated_contact.last_name}".strip(), + actor=current_user, + request=request, + before_data=before_data, + after_data=updated_contact, + metadata={"customer_id": customer_id, "customer_label": customer.company_name}, + ) + return updated_contact @router.delete("/{customer_id}/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_customer_contact( customer_id: int, contact_id: int, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("customers.update")), ): - get_customer_or_404(db, customer_id) + customer = get_customer_or_404(db, customer_id) db_contact = get_contact_or_404(db, customer_id, contact_id) + before_data = sanitize(db_contact) + entity_label = f"{db_contact.first_name} {db_contact.last_name}".strip() logger.info( "customers.contacts.delete", extra={ @@ -205,4 +277,15 @@ def delete_customer_contact( }, ) CustomerRepository.delete_contact(db, db_contact) + write_audit_log( + db, + action="customer_contacts.delete", + entity_type="customer_contacts", + entity_id=contact_id, + entity_label=entity_label, + actor=current_user, + request=request, + before_data=before_data, + metadata={"customer_id": customer_id, "customer_label": customer.company_name}, + ) return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/hermes/app/api/roles.py b/backend/hermes/app/api/roles.py index 72ae4bc..bc9c9ea 100644 --- a/backend/hermes/app/api/roles.py +++ b/backend/hermes/app/api/roles.py @@ -3,12 +3,14 @@ import logging from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy import select 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.rbac_repository import RbacRepository from app.schemas.rbac import RoleCreate, RolePermissionUpdate, RoleResponse, RoleUpdate +from app.services.audit_service import sanitize, write_audit_log logger = logging.getLogger(__name__) @@ -80,6 +82,7 @@ def get_role( ) def create_role( role: RoleCreate, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("roles.create")), ): @@ -87,17 +90,30 @@ def create_role( ensure_permission_ids_exist(db, role.permission_ids) logger.info("roles.create", extra={"actor_user_id": current_user.id}) - return RbacRepository.create_role(db, role) + created_role = RbacRepository.create_role(db, role) + write_audit_log( + db, + action="roles.create", + entity_type="roles", + entity_id=created_role.id, + entity_label=created_role.name, + actor=current_user, + request=request, + after_data=created_role, + ) + return created_role @router.put("/{role_id}", response_model=RoleResponse) def update_role( role_id: int, role: RoleUpdate, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("roles.update")), ): db_role = get_role_or_404(db, role_id) + before_data = sanitize(db_role) ensure_unique_role_name(db, role.name, role_id) ensure_permission_ids_exist(db, role.permission_ids) @@ -105,16 +121,31 @@ def update_role( "roles.update", extra={"actor_user_id": current_user.id, "target_role_id": role_id}, ) - return RbacRepository.update_role(db, db_role, role) + updated_role = RbacRepository.update_role(db, db_role, role) + write_audit_log( + db, + action="roles.update", + entity_type="roles", + entity_id=updated_role.id, + entity_label=updated_role.name, + actor=current_user, + request=request, + before_data=before_data, + after_data=updated_role, + ) + return updated_role @router.delete("/{role_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_role( role_id: int, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("roles.delete")), ): db_role = get_role_or_404(db, role_id) + before_data = sanitize(db_role) + entity_label = db_role.name if db_role.is_system: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -133,6 +164,16 @@ def delete_role( extra={"actor_user_id": current_user.id, "target_role_id": role_id}, ) RbacRepository.delete_role(db, db_role) + write_audit_log( + db, + action="roles.delete", + entity_type="roles", + entity_id=role_id, + entity_label=entity_label, + actor=current_user, + request=request, + before_data=before_data, + ) return Response(status_code=status.HTTP_204_NO_CONTENT) @@ -140,14 +181,28 @@ def delete_role( def update_role_permissions( role_id: int, permission_update: RolePermissionUpdate, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("roles.assign")), ): db_role = get_role_or_404(db, role_id) + before_data = sanitize(db_role) ensure_permission_ids_exist(db, permission_update.permission_ids) logger.info( "roles.permissions_update", extra={"actor_user_id": current_user.id, "target_role_id": role_id}, ) - return RbacRepository.update_role_permissions(db, db_role, permission_update) + updated_role = RbacRepository.update_role_permissions(db, db_role, permission_update) + write_audit_log( + db, + action="roles.permissions_update", + entity_type="roles", + entity_id=updated_role.id, + entity_label=updated_role.name, + actor=current_user, + request=request, + before_data=before_data, + after_data=updated_role, + ) + return updated_role diff --git a/backend/hermes/app/api/users.py b/backend/hermes/app/api/users.py index e394cb7..2bd277b 100644 --- a/backend/hermes/app/api/users.py +++ b/backend/hermes/app/api/users.py @@ -2,6 +2,7 @@ import logging from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy.orm import Session +from starlette.requests import Request from app.core.rbac import get_user_permission_names, require_permission from app.db.database import get_db @@ -9,6 +10,7 @@ from app.models.user import User from app.repositories.rbac_repository import RbacRepository from app.repositories.user_repository import UserRepository from app.schemas.user import UserCreate, UserPasswordUpdate, UserResponse, UserUpdate +from app.services.audit_service import sanitize, write_audit_log logger = logging.getLogger(__name__) @@ -98,6 +100,7 @@ def get_user( ) def create_user( user: UserCreate, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("users.create")), ): @@ -113,17 +116,30 @@ def create_user( raise_conflict(conflict[0]) logger.info("users.create", extra={"actor_user_id": current_user.id}) - return UserRepository.create(db, user) + created_user = UserRepository.create(db, user) + write_audit_log( + db, + action="users.create", + entity_type="users", + entity_id=created_user.id, + entity_label=created_user.username, + actor=current_user, + request=request, + after_data=created_user, + ) + return created_user @router.put("/{user_id}", response_model=UserResponse) def update_user( user_id: int, user: UserUpdate, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("users.update")), ): db_user = get_user_or_404(db, user_id) + before_data = sanitize(db_user) apply_role(db, user) if user.role_id != db_user.role_id: require_actor_permission(current_user, "roles.assign") @@ -141,28 +157,56 @@ def update_user( "users.update", extra={"actor_user_id": current_user.id, "target_user_id": user_id}, ) - return UserRepository.update(db, db_user, user) + updated_user = UserRepository.update(db, db_user, user) + write_audit_log( + db, + action="users.update", + entity_type="users", + entity_id=updated_user.id, + entity_label=updated_user.username, + actor=current_user, + request=request, + before_data=before_data, + after_data=updated_user, + ) + return updated_user @router.put("/{user_id}/password", response_model=UserResponse) def update_user_password( user_id: int, password_update: UserPasswordUpdate, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("users.password.update")), ): db_user = get_user_or_404(db, user_id) + before_data = sanitize(db_user) logger.info( "users.password_update", extra={"actor_user_id": current_user.id, "target_user_id": user_id}, ) - return UserRepository.update_password(db, db_user, password_update) + updated_user = UserRepository.update_password(db, db_user, password_update) + write_audit_log( + db, + action="users.password_update", + entity_type="users", + entity_id=updated_user.id, + entity_label=updated_user.username, + actor=current_user, + request=request, + before_data=before_data, + after_data=updated_user, + metadata={"password_changed": True}, + ) + return updated_user @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_user( user_id: int, + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("users.delete")), ): @@ -173,10 +217,22 @@ def delete_user( ) db_user = get_user_or_404(db, user_id) + before_data = sanitize(db_user) + entity_label = db_user.username logger.info( "users.delete", extra={"actor_user_id": current_user.id, "target_user_id": user_id}, ) UserRepository.delete(db, db_user) + write_audit_log( + db, + action="users.delete", + entity_type="users", + entity_id=user_id, + entity_label=entity_label, + actor=current_user, + request=request, + before_data=before_data, + ) return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/hermes/app/core/config.py b/backend/hermes/app/core/config.py index 8a19737..685d618 100644 --- a/backend/hermes/app/core/config.py +++ b/backend/hermes/app/core/config.py @@ -9,6 +9,7 @@ class Settings(BaseSettings): app_version: str = "0.1.0" access_token_expire_minutes: int = 60 jwt_issuer: str = "hermes" + log_level: str = "INFO" model_config = SettingsConfigDict( env_file=".env", diff --git a/backend/hermes/app/core/logging.py b/backend/hermes/app/core/logging.py new file mode 100644 index 0000000..20f777b --- /dev/null +++ b/backend/hermes/app/core/logging.py @@ -0,0 +1,14 @@ +import logging +import sys + +from app.core.config import settings + + +def configure_logging() -> None: + level = getattr(logging, settings.log_level.upper(), logging.INFO) + logging.basicConfig( + level=level, + format="%(asctime)s %(levelname)s %(name)s %(message)s", + stream=sys.stdout, + force=True, + ) diff --git a/backend/hermes/app/db/database.py b/backend/hermes/app/db/database.py index dce9d72..3537b5a 100644 --- a/backend/hermes/app/db/database.py +++ b/backend/hermes/app/db/database.py @@ -22,6 +22,7 @@ class Base(DeclarativeBase): # <<< HIER IMPORTIEREN >>> import app.models.rbac import app.models.customer +import app.models.audit import app.models.user diff --git a/backend/hermes/app/main.py b/backend/hermes/app/main.py index a5de34d..2ef7a74 100644 --- a/backend/hermes/app/main.py +++ b/backend/hermes/app/main.py @@ -1,4 +1,5 @@ import logging +import time from fastapi import FastAPI from fastapi import HTTPException @@ -9,6 +10,7 @@ from starlette import status from starlette.requests import Request from app.api.auth import router as auth_router +from app.api.audit import router as audit_router from app.api.customers import router as customers_router from app.api.dashboard import router as dashboard_router from app.api.permissions import router as permissions_router @@ -16,8 +18,11 @@ from app.api.roles import router as roles_router from app.api.users import router as users_router from app.db.database import SessionLocal from app.db.health import check_database +from app.core.logging import configure_logging from app.rbac.seed import seed_rbac +configure_logging() + app = FastAPI( title="Hermes API", version="0.1.0", @@ -25,6 +30,7 @@ app = FastAPI( ) app.include_router(auth_router) +app.include_router(audit_router) app.include_router(users_router) app.include_router(roles_router) app.include_router(permissions_router) @@ -34,6 +40,34 @@ app.include_router(dashboard_router) logger = logging.getLogger(__name__) +@app.middleware("http") +async def request_logging_middleware(request: Request, call_next): + start = time.perf_counter() + response = await call_next(request) + duration_ms = round((time.perf_counter() - start) * 1000, 2) + logger.info( + "request.completed", + extra={ + "method": request.method, + "path": request.url.path, + "status_code": response.status_code, + "duration_ms": duration_ms, + }, + ) + return response + + +def error_code_for_status(status_code: int) -> str: + return { + 400: "BAD_REQUEST", + 401: "UNAUTHORIZED", + 403: "FORBIDDEN", + 404: "NOT_FOUND", + 409: "CONFLICT", + 422: "VALIDATION_ERROR", + }.get(status_code, "HTTP_ERROR") + + @app.on_event("startup") def startup_seed_rbac(): db = SessionLocal() @@ -55,7 +89,13 @@ async def http_exception_handler(request: Request, exc: HTTPException): ) return JSONResponse( status_code=exc.status_code, - content={"detail": exc.detail}, + content={ + "success": False, + "message": str(exc.detail), + "error_code": error_code_for_status(exc.status_code), + "details": [], + "detail": exc.detail, + }, headers=exc.headers, ) @@ -68,7 +108,13 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE ) return JSONResponse( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - content={"detail": jsonable_encoder(exc.errors())}, + content={ + "success": False, + "message": "Validierung fehlgeschlagen", + "error_code": "VALIDATION_ERROR", + "details": jsonable_encoder(exc.errors()), + "detail": jsonable_encoder(exc.errors()), + }, ) @@ -80,7 +126,13 @@ async def unhandled_exception_handler(request: Request, exc: Exception): ) return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={"detail": "Interner Serverfehler"}, + content={ + "success": False, + "message": "Interner Serverfehler", + "error_code": "INTERNAL_SERVER_ERROR", + "details": [], + "detail": "Interner Serverfehler", + }, ) diff --git a/backend/hermes/app/models/audit.py b/backend/hermes/app/models/audit.py new file mode 100644 index 0000000..3a182b7 --- /dev/null +++ b/backend/hermes/app/models/audit.py @@ -0,0 +1,28 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.database import Base + + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id: Mapped[int] = mapped_column(primary_key=True) + actor_user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + actor_username: Mapped[str] = mapped_column(String(120), default="", server_default="") + action: Mapped[str] = mapped_column(String(120), index=True) + entity_type: Mapped[str] = mapped_column(String(80), index=True) + entity_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + entity_label: Mapped[str] = mapped_column(String(255), default="", server_default="") + ip_address: Mapped[str] = mapped_column(String(80), default="", server_default="") + user_agent: Mapped[str] = mapped_column(Text, default="", server_default="") + before_data: Mapped[dict | list | None] = mapped_column(JSON, nullable=True) + after_data: Mapped[dict | list | None] = mapped_column(JSON, nullable=True) + metadata_data: Mapped[dict | list | None] = mapped_column("metadata", JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/hermes/app/rbac/defaults.py b/backend/hermes/app/rbac/defaults.py index 0c7b9fa..ff244bf 100644 --- a/backend/hermes/app/rbac/defaults.py +++ b/backend/hermes/app/rbac/defaults.py @@ -69,6 +69,7 @@ STANDARD_PERMISSIONS = [ ("dashboard.read", "Dashboard lesen", "Dashboard anzeigen", "dashboard"), ("system.settings.read", "Einstellungen lesen", "Systemeinstellungen anzeigen", "system"), ("system.settings.update", "Einstellungen bearbeiten", "Systemeinstellungen aktualisieren", "system"), + ("audit_logs.read", "Audit Logs lesen", "Audit Logs anzeigen", "audit_logs"), ] ROLE_PERMISSION_NAMES = { diff --git a/backend/hermes/app/repositories/audit_repository.py b/backend/hermes/app/repositories/audit_repository.py new file mode 100644 index 0000000..2277e05 --- /dev/null +++ b/backend/hermes/app/repositories/audit_repository.py @@ -0,0 +1,104 @@ +from datetime import datetime + +from sqlalchemy import Select, func, select +from sqlalchemy.orm import Session + +from app.models.audit import AuditLog + + +class AuditRepository: + @staticmethod + def create( + db: Session, + *, + actor_user_id: int | None, + actor_username: str, + action: str, + entity_type: str, + entity_id: int | None, + entity_label: str, + ip_address: str, + user_agent: str, + before_data: dict | list | None = None, + after_data: dict | list | None = None, + metadata: dict | list | None = None, + ) -> AuditLog: + audit_log = AuditLog( + actor_user_id=actor_user_id, + actor_username=actor_username, + action=action, + entity_type=entity_type, + entity_id=entity_id, + entity_label=entity_label, + ip_address=ip_address, + user_agent=user_agent, + before_data=before_data, + after_data=after_data, + metadata_data=metadata, + ) + db.add(audit_log) + db.commit() + db.refresh(audit_log) + return audit_log + + @staticmethod + def query( + *, + entity_type: str | None = None, + actor_user_id: int | None = None, + action: str | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, + ) -> Select[tuple[AuditLog]]: + query = select(AuditLog) + if entity_type: + query = query.where(AuditLog.entity_type == entity_type) + if actor_user_id is not None: + query = query.where(AuditLog.actor_user_id == actor_user_id) + if action: + query = query.where(AuditLog.action == action) + if date_from: + query = query.where(AuditLog.created_at >= date_from) + if date_to: + query = query.where(AuditLog.created_at <= date_to) + return query + + @staticmethod + def list( + db: Session, + *, + page: int, + page_size: int, + entity_type: str | None = None, + actor_user_id: int | None = None, + action: str | None = None, + date_from: datetime | None = None, + date_to: datetime | None = None, + ) -> tuple[list[AuditLog], int]: + base_query = AuditRepository.query( + entity_type=entity_type, + actor_user_id=actor_user_id, + action=action, + date_from=date_from, + date_to=date_to, + ) + total = db.scalar(select(func.count()).select_from(base_query.subquery())) or 0 + items = list( + db.scalars( + base_query + .order_by(AuditLog.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + ) + return items, total + + @staticmethod + def latest(db: Session, limit: int = 10) -> list[AuditLog]: + return list( + db.scalars( + select(AuditLog) + .order_by(AuditLog.created_at.desc()) + .limit(limit) + ) + ) diff --git a/backend/hermes/app/schemas/api_response.py b/backend/hermes/app/schemas/api_response.py new file mode 100644 index 0000000..0173f23 --- /dev/null +++ b/backend/hermes/app/schemas/api_response.py @@ -0,0 +1,16 @@ +from typing import Any + +from pydantic import BaseModel, Field + + +class ApiSuccess(BaseModel): + success: bool = True + data: Any = None + message: str = "" + + +class ApiError(BaseModel): + success: bool = False + message: str + error_code: str + details: list[Any] = Field(default_factory=list) diff --git a/backend/hermes/app/schemas/audit.py b/backend/hermes/app/schemas/audit.py new file mode 100644 index 0000000..e576b86 --- /dev/null +++ b/backend/hermes/app/schemas/audit.py @@ -0,0 +1,39 @@ +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class AuditLogResponse(BaseModel): + id: int + actor_user_id: int | None + actor_username: str + action: str + entity_type: str + entity_id: int | None + entity_label: str + ip_address: str + user_agent: str + before_data: Any = None + after_data: Any = None + metadata_data: Any = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class AuditLogListResponse(BaseModel): + items: list[AuditLogResponse] + total: int + page: int + page_size: int + + +class ActivityFeedItem(BaseModel): + id: int + title: str + description: str + actor: str + entity_type: str + entity_id: int | None + created_at: datetime diff --git a/backend/hermes/app/services/audit_service.py b/backend/hermes/app/services/audit_service.py new file mode 100644 index 0000000..f981a1d --- /dev/null +++ b/backend/hermes/app/services/audit_service.py @@ -0,0 +1,148 @@ +import logging +from typing import Any + +from fastapi.encoders import jsonable_encoder +from sqlalchemy import inspect +from sqlalchemy.exc import NoInspectionAvailable +from sqlalchemy.orm import Session +from starlette.requests import Request + +from app.models.audit import AuditLog +from app.models.user import User +from app.repositories.audit_repository import AuditRepository + +logger = logging.getLogger(__name__) + +SENSITIVE_KEYS = {"password", "password_hash", "token", "access_token", "secret", "secret_key"} + + +def to_audit_data(value: Any, seen: set[int] | None = None) -> Any: + seen = seen or set() + + if value is None or isinstance(value, str | int | float | bool): + return value + + value_id = id(value) + if value_id in seen: + return None + + if isinstance(value, list | tuple | set): + seen.add(value_id) + return [to_audit_data(item, seen) for item in value] + + if isinstance(value, dict): + seen.add(value_id) + return {key: to_audit_data(item, seen) for key, item in value.items()} + + try: + state = inspect(value) + except NoInspectionAvailable: + return jsonable_encoder(value) + + seen.add(value_id) + data: dict[str, Any] = {} + + for column in state.mapper.column_attrs: + data[column.key] = getattr(value, column.key) + + for relationship in state.mapper.relationships: + if relationship.key in state.unloaded: + continue + related_value = getattr(value, relationship.key) + data[relationship.key] = to_audit_data(related_value, seen) + + return data + + +def sanitize(value: Any) -> Any: + encoded = jsonable_encoder(to_audit_data(value)) + if isinstance(encoded, dict): + return { + key: "***" if key in SENSITIVE_KEYS else sanitize(item) + for key, item in encoded.items() + } + if isinstance(encoded, list): + return [sanitize(item) for item in encoded] + return encoded + + +def request_ip(request: Request | None) -> str: + if request is None: + return "" + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "" + + +def write_audit_log( + db: Session, + *, + action: str, + entity_type: str, + entity_id: int | None = None, + entity_label: str = "", + actor: User | None = None, + actor_user_id: int | None = None, + actor_username: str = "", + request: Request | None = None, + before_data: Any = None, + after_data: Any = None, + metadata: Any = None, +) -> None: + try: + AuditRepository.create( + db, + actor_user_id=actor.id if actor is not None else actor_user_id, + actor_username=actor.username if actor is not None else actor_username, + action=action, + entity_type=entity_type, + entity_id=entity_id, + entity_label=entity_label, + ip_address=request_ip(request), + user_agent=request.headers.get("user-agent", "") if request is not None else "", + before_data=sanitize(before_data), + after_data=sanitize(after_data), + metadata=sanitize(metadata), + ) + except Exception: + db.rollback() + logger.exception("audit.write_failed", extra={"action": action, "entity_type": entity_type}) + + +def action_title(action: str) -> str: + labels = { + "auth.login.success": "Login erfolgreich", + "auth.login.failed": "Login fehlgeschlagen", + "auth.logout": "Logout", + "users.create": "Benutzer erstellt", + "users.update": "Benutzer bearbeitet", + "users.delete": "Benutzer gelöscht", + "users.password_update": "Passwort geändert", + "roles.create": "Rolle erstellt", + "roles.update": "Rolle bearbeitet", + "roles.delete": "Rolle gelöscht", + "roles.permissions_update": "Rollenberechtigungen geändert", + "customers.create": "Kunde erstellt", + "customers.update": "Kunde bearbeitet", + "customers.delete": "Kunde gelöscht", + "customer_contacts.create": "Ansprechpartner erstellt", + "customer_contacts.update": "Ansprechpartner bearbeitet", + "customer_contacts.delete": "Ansprechpartner gelöscht", + } + return labels.get(action, action) + + +def audit_to_activity(audit_log: AuditLog) -> dict[str, Any]: + title = action_title(audit_log.action) + actor = audit_log.actor_username or "System" + label = audit_log.entity_label or audit_log.entity_type + return { + "id": audit_log.id, + "title": title, + "description": f"{actor} · {label}", + "actor": actor, + "entity_type": audit_log.entity_type, + "entity_id": audit_log.entity_id, + "created_at": audit_log.created_at, + } diff --git a/backend/hermes/docker-compose.yml b/backend/hermes/docker-compose.yml index 1f0635e..c0816c2 100644 --- a/backend/hermes/docker-compose.yml +++ b/backend/hermes/docker-compose.yml @@ -11,6 +11,7 @@ services: SECRET_KEY: ${SECRET_KEY} ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} JWT_ISSUER: ${JWT_ISSUER:-hermes} + LOG_LEVEL: ${LOG_LEVEL:-INFO} ports: - "8000:8000" diff --git a/docker-compose.yml b/docker-compose.yml index f8ce432..f0768fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,9 +14,10 @@ services: SECRET_KEY: ${SECRET_KEY} ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} JWT_ISSUER: ${JWT_ISSUER:-hermes} + LOG_LEVEL: ${LOG_LEVEL:-INFO} - ports: - - "8000:8000" + expose: + - "8000" networks: - olympus-network diff --git a/frontend/athena/app/api/activity-feed/route.ts b/frontend/athena/app/api/activity-feed/route.ts new file mode 100644 index 0000000..962a3b7 --- /dev/null +++ b/frontend/athena/app/api/activity-feed/route.ts @@ -0,0 +1,7 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; + +export async function GET(request: NextRequest) { + return proxyHermesRequest(request, `/activity-feed${request.nextUrl.search}`); +} diff --git a/frontend/athena/app/api/audit-logs/route.ts b/frontend/athena/app/api/audit-logs/route.ts new file mode 100644 index 0000000..486c3b5 --- /dev/null +++ b/frontend/athena/app/api/audit-logs/route.ts @@ -0,0 +1,7 @@ +import { NextRequest } from "next/server"; + +import { proxyHermesRequest } from "@/lib/server/hermes-proxy"; + +export async function GET(request: NextRequest) { + return proxyHermesRequest(request, `/audit-logs${request.nextUrl.search}`); +} diff --git a/frontend/athena/app/audit-logs/page.tsx b/frontend/athena/app/audit-logs/page.tsx new file mode 100644 index 0000000..7d84590 --- /dev/null +++ b/frontend/athena/app/audit-logs/page.tsx @@ -0,0 +1,294 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Eye, Filter, RotateCcw } from "lucide-react"; + +import DataTable, { type DataTableColumn } from "@/components/common/DataTable"; +import SearchInput from "@/components/common/SearchInput"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api } from "@/lib/api"; +import type { ApiSuccess, AuditLog, AuditLogListResponse } from "@/types/audit"; + +const pageSize = 20; + +function getErrorMessage(error: unknown) { + if (typeof error === "object" && error !== null && "response" in error) { + const response = (error as { response?: { data?: { detail?: string; message?: string } } }).response; + return response?.data?.message ?? response?.data?.detail ?? "Audit Logs konnten nicht geladen werden"; + } + return "Audit Logs konnten nicht geladen werden"; +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat("de-DE", { + dateStyle: "short", + timeStyle: "medium", + }).format(new Date(value)); +} + +function formatJson(value: unknown) { + if (value === null || value === undefined) { + return "-"; + } + + return JSON.stringify(value, null, 2); +} + +export default function AuditLogsPage() { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [entityType, setEntityType] = useState(""); + const [action, setAction] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [selectedLog, setSelectedLog] = useState(null); + + const loadAuditLogs = useCallback(async () => { + setLoading(true); + setError(""); + + try { + const params = new URLSearchParams({ + page: String(page), + page_size: String(pageSize), + }); + + if (entityType.trim()) { + params.set("entity_type", entityType.trim()); + } + + if (action.trim()) { + params.set("action", action.trim()); + } + + const response = await api.get>(`/audit-logs?${params.toString()}`); + setItems(response.data.data.items); + setTotal(response.data.data.total); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + }, [action, entityType, page]); + + useEffect(() => { + queueMicrotask(() => { + void loadAuditLogs(); + }); + }, [loadAuditLogs]); + + const filteredItems = useMemo(() => { + const normalized = search.trim().toLowerCase(); + if (!normalized) { + return items; + } + + return items.filter((item) => ( + item.actor_username.toLowerCase().includes(normalized) + || item.action.toLowerCase().includes(normalized) + || item.entity_type.toLowerCase().includes(normalized) + || item.entity_label.toLowerCase().includes(normalized) + )); + }, [items, search]); + + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + + const columns: DataTableColumn[] = [ + { + key: "created_at", + label: "Zeitpunkt", + render: (log) => {formatDate(log.created_at)}, + }, + { + key: "actor_username", + label: "Benutzer", + render: (log) => log.actor_username || "System", + }, + { + key: "action", + label: "Aktion", + render: (log) => {log.action}, + }, + { + key: "entity", + label: "Objekt", + render: (log) => ( +
+

{log.entity_label || "-"}

+

{log.entity_type}{log.entity_id ? ` #${log.entity_id}` : ""}

+
+ ), + }, + { + key: "ip_address", + label: "IP", + render: (log) => log.ip_address || "-", + }, + { + key: "details", + label: "", + className: "px-4 py-3 text-right", + render: (log) => ( + + ), + }, + ]; + + return ( +
+
+
+

Audit Logs

+

Nachvollziehbare Systemereignisse und Änderungen

+
+ +
+ +
+
+ + +
+ + { + setEntityType(event.target.value); + setPage(1); + }} + placeholder="customers" + /> +
+ +
+ + { + setAction(event.target.value); + setPage(1); + }} + placeholder="users.update" + /> +
+ + +
+
+ + row.id} + sortKey="created_at" + sortDirection="desc" + loading={loading} + error={error} + emptyTitle="Keine Audit Logs gefunden" + emptyDescription="Für die aktuellen Filter sind keine Einträge vorhanden." + onSort={() => undefined} + /> + +
+ {total} Einträge +
+ + Seite {page} von {totalPages} + +
+
+ + !open && setSelectedLog(null)}> + + + Audit Log Details + + {selectedLog && ( +
+
+ + + + + + +
+ +
+ + + +
+
+ )} +
+
+
+ ); +} + +function Detail({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function JsonPanel({ title, value }: { title: string; value: unknown }) { + return ( +
+

{title}

+
+        {formatJson(value)}
+      
+
+ ); +} diff --git a/frontend/athena/app/customers/[id]/page.tsx b/frontend/athena/app/customers/[id]/page.tsx index 7ef74f0..12ee9c8 100644 --- a/frontend/athena/app/customers/[id]/page.tsx +++ b/frontend/athena/app/customers/[id]/page.tsx @@ -7,6 +7,7 @@ import { ArrowLeft, Edit, Plus, Trash2 } from "lucide-react"; import ConfirmDialog from "@/components/common/ConfirmDialog"; import DetailSection from "@/components/common/DetailSection"; +import { useToast } from "@/components/common/ToastProvider"; import { Button } from "@/components/ui/button"; import CustomerContactFormDialog from "@/components/customers/CustomerContactFormDialog"; import CustomerFormDialog from "@/components/customers/CustomerFormDialog"; @@ -32,6 +33,7 @@ function formatDateTime(value: string) { } export default function CustomerDetailPage() { + const { showToast } = useToast(); const params = useParams<{ id: string }>(); const router = useRouter(); const [customer, setCustomer] = useState(null); @@ -79,8 +81,11 @@ export default function CustomerDetailPage() { const response = await api.put(`/customers/${customer.id}`, payload); setCustomer(response.data); setFormOpen(false); + showToast({ type: "success", title: "Kunde aktualisiert", description: response.data.company_name }); } catch (err) { - setFormError(getErrorMessage(err)); + const message = getErrorMessage(err); + setFormError(message); + showToast({ type: "error", title: "Kunde konnte nicht gespeichert werden", description: message }); } finally { setPending(false); } @@ -101,14 +106,18 @@ export default function CustomerDetailPage() { try { if (selectedContact) { await api.put(`/customers/${customer.id}/contacts/${selectedContact.id}`, payload); + showToast({ type: "success", title: "Ansprechpartner aktualisiert" }); } else { await api.post(`/customers/${customer.id}/contacts`, payload); + showToast({ type: "success", title: "Ansprechpartner erstellt" }); } const response = await api.get(`/customers/${customer.id}`); setCustomer(response.data); setContactOpen(false); } catch (err) { - setContactError(getErrorMessage(err)); + const message = getErrorMessage(err); + setContactError(message); + showToast({ type: "error", title: "Ansprechpartner konnte nicht gespeichert werden", description: message }); } finally { setPending(false); } @@ -124,9 +133,12 @@ export default function CustomerDetailPage() { await api.delete(`/customers/${customer.id}/contacts/${deleteContact.id}`); const response = await api.get(`/customers/${customer.id}`); setCustomer(response.data); + showToast({ type: "success", title: "Ansprechpartner gelöscht", description: `${deleteContact.first_name} ${deleteContact.last_name}` }); setDeleteContact(null); } catch (err) { - setDeleteError(getErrorMessage(err)); + const message = getErrorMessage(err); + setDeleteError(message); + showToast({ type: "error", title: "Ansprechpartner konnte nicht gelöscht werden", description: message }); } finally { setPending(false); } @@ -139,10 +151,14 @@ export default function CustomerDetailPage() { setPending(true); setDeleteError(""); try { + const deletedCustomerName = customer.company_name; await api.delete(`/customers/${customer.id}`); + showToast({ type: "success", title: "Kunde gelöscht", description: deletedCustomerName }); router.push("/customers"); } catch (err) { - setDeleteError(getErrorMessage(err)); + const message = getErrorMessage(err); + setDeleteError(message); + showToast({ type: "error", title: "Kunde konnte nicht gelöscht werden", description: message }); } finally { setPending(false); } diff --git a/frontend/athena/app/customers/page.tsx b/frontend/athena/app/customers/page.tsx index ee72ae6..69a9e1a 100644 --- a/frontend/athena/app/customers/page.tsx +++ b/frontend/athena/app/customers/page.tsx @@ -7,6 +7,7 @@ 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 { Button, buttonVariants } from "@/components/ui/button"; import CustomerFormDialog from "@/components/customers/CustomerFormDialog"; import CustomerStatusBadge from "@/components/customers/CustomerStatusBadge"; @@ -48,6 +49,7 @@ function getSortableValue(customer: Customer, key: string) { } export default function CustomersPage() { + const { showToast } = useToast(); const [customers, setCustomers] = useState([]); const [currentUser, setCurrentUser] = useState(null); const [loading, setLoading] = useState(true); @@ -183,13 +185,17 @@ export default function CustomersPage() { if (selectedCustomer) { const response = await api.put(`/customers/${selectedCustomer.id}`, payload); setCustomers((current) => current.map((customer) => customer.id === selectedCustomer.id ? response.data : customer)); + showToast({ type: "success", title: "Kunde aktualisiert", description: response.data.company_name }); } else { const response = await api.post("/customers", payload); setCustomers((current) => [response.data, ...current]); + showToast({ type: "success", title: "Kunde erstellt", description: response.data.company_name }); } setFormOpen(false); } catch (err) { - setFormError(getErrorMessage(err)); + const message = getErrorMessage(err); + setFormError(message); + showToast({ type: "error", title: "Kunde konnte nicht gespeichert werden", description: message }); } finally { setPending(false); } @@ -204,9 +210,12 @@ export default function CustomersPage() { try { await api.delete(`/customers/${deleteCustomer.id}`); setCustomers((current) => current.filter((customer) => customer.id !== deleteCustomer.id)); + showToast({ type: "success", title: "Kunde gelöscht", description: deleteCustomer.company_name }); setDeleteCustomer(null); } catch (err) { - setDeleteError(getErrorMessage(err)); + const message = getErrorMessage(err); + setDeleteError(message); + showToast({ type: "error", title: "Kunde konnte nicht gelöscht werden", description: message }); } finally { setPending(false); } diff --git a/frontend/athena/app/dashboard/page.tsx b/frontend/athena/app/dashboard/page.tsx index bb1d1b2..be833b6 100644 --- a/frontend/athena/app/dashboard/page.tsx +++ b/frontend/athena/app/dashboard/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import SummaryCard from "@/components/common/SummaryCard"; import { api } from "@/lib/api"; +import type { ActivityFeedItem, ApiSuccess } from "@/types/audit"; import type { DashboardSummary, EmptyWidget } from "@/types/dashboard"; function getErrorMessage(error: unknown) { @@ -17,13 +18,19 @@ function getErrorMessage(error: unknown) { export default function DashboardPage() { const [summary, setSummary] = useState(null); + const [activities, setActivities] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); + const [activityError, setActivityError] = useState(""); const loadSummary = useCallback(async () => { try { - const response = await api.get("/dashboard/summary"); - setSummary(response.data); + const [summaryResponse, activityResponse] = await Promise.all([ + api.get("/dashboard/summary"), + api.get>("/activity-feed?limit=10"), + ]); + setSummary(summaryResponse.data); + setActivities(activityResponse.data.data); } catch (err) { setError(getErrorMessage(err)); } finally { @@ -31,6 +38,16 @@ export default function DashboardPage() { } }, []); + const loadActivities = useCallback(async () => { + setActivityError(""); + try { + const response = await api.get>("/activity-feed?limit=10"); + setActivities(response.data.data); + } catch (err) { + setActivityError(getErrorMessage(err)); + } + }, []); + useEffect(() => { queueMicrotask(() => { void loadSummary(); @@ -92,7 +109,12 @@ export default function DashboardPage() {
- + void loadActivities()} + /> @@ -101,6 +123,54 @@ export default function DashboardPage() { ); } +function ActivityPanel({ + widget, + activities, + error, + onReload, +}: { + widget: EmptyWidget; + activities: ActivityFeedItem[]; + error: string; + onReload: () => void; +}) { + return ( +
+
+

{widget.title}

+ +
+ + {error ? ( +

{error}

+ ) : activities.length === 0 ? ( +

{widget.message}

+ ) : ( +
+ {activities.map((activity) => ( +
+
+

{activity.title}

+ +
+

+ {activity.description} · {activity.actor || "System"} +

+
+ ))} +
+ )} +
+ ); +} + function EmptyPanel({ widget }: { widget: EmptyWidget }) { return (
diff --git a/frontend/athena/app/layout.tsx b/frontend/athena/app/layout.tsx index 7de60c4..7692295 100644 --- a/frontend/athena/app/layout.tsx +++ b/frontend/athena/app/layout.tsx @@ -3,6 +3,7 @@ import "./globals.css"; import Sidebar from "@/components/Sidebar"; import Header from "@/components/Header"; +import { ToastProvider } from "@/components/common/ToastProvider"; export const metadata: Metadata = { title: "Olympus ERP", @@ -18,25 +19,27 @@ export default function RootLayout({ -
+ +
- + -
+
-
+
-
+
- {children} + {children} -
+
+ +
- -
+
); -} \ No newline at end of file +} diff --git a/frontend/athena/app/roles/page.tsx b/frontend/athena/app/roles/page.tsx index 96787de..2fbb84b 100644 --- a/frontend/athena/app/roles/page.tsx +++ b/frontend/athena/app/roles/page.tsx @@ -7,6 +7,7 @@ import { Edit, Eye, Shield, 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 { Button } from "@/components/ui/button"; import { Dialog, @@ -47,6 +48,7 @@ function getSortableValue(role: Role, key: string) { } export default function RolesPage() { + const { showToast } = useToast(); const [roles, setRoles] = useState([]); const [permissions, setPermissions] = useState([]); const [currentUser, setCurrentUser] = useState(null); @@ -217,14 +219,18 @@ export default function RolesPage() { setRoles((current) => current.map((role) => ( role.id === selectedRole.id ? response.data : role ))); + showToast({ type: "success", title: "Rolle aktualisiert", description: response.data.display_name }); } else { const response = await api.post("/roles", payload); setRoles((current) => [response.data, ...current]); + showToast({ type: "success", title: "Rolle erstellt", description: response.data.display_name }); } setFormOpen(false); } catch (err) { - setFormError(getErrorMessage(err)); + const message = getErrorMessage(err); + setFormError(message); + showToast({ type: "error", title: "Rolle konnte nicht gespeichert werden", description: message }); } finally { setPending(false); } @@ -241,9 +247,12 @@ export default function RolesPage() { try { await api.delete(`/roles/${deleteRole.id}`); setRoles((current) => current.filter((role) => role.id !== deleteRole.id)); + showToast({ type: "success", title: "Rolle gelöscht", description: deleteRole.display_name }); setDeleteRole(null); } catch (err) { - setDeleteError(getErrorMessage(err)); + const message = getErrorMessage(err); + setDeleteError(message); + showToast({ type: "error", title: "Rolle konnte nicht gelöscht werden", description: message }); } finally { setPending(false); } diff --git a/frontend/athena/app/users/[id]/page.tsx b/frontend/athena/app/users/[id]/page.tsx index a5c1c5e..9769b00 100644 --- a/frontend/athena/app/users/[id]/page.tsx +++ b/frontend/athena/app/users/[id]/page.tsx @@ -7,6 +7,7 @@ import { useParams } from "next/navigation"; import { ArrowLeft, KeyRound, Pencil } from "lucide-react"; import StatusBadge from "@/components/common/StatusBadge"; +import { useToast } from "@/components/common/ToastProvider"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -41,6 +42,7 @@ function formatDateTime(value: string) { } export default function UserDetailPage() { + const { showToast } = useToast(); const params = useParams<{ id: string }>(); const [user, setUser] = useState(null); const [roles, setRoles] = useState([]); @@ -94,8 +96,11 @@ export default function UserDetailPage() { const response = await api.put(`/users/${user.id}`, payload); setUser(response.data); setFormOpen(false); + showToast({ type: "success", title: "Benutzer aktualisiert", description: response.data.username }); } catch (err) { - setFormError(getErrorMessage(err)); + const message = getErrorMessage(err); + setFormError(message); + showToast({ type: "error", title: "Benutzer konnte nicht gespeichert werden", description: message }); } finally { setPending(false); } @@ -119,8 +124,11 @@ export default function UserDetailPage() { setUser(response.data); setPassword(""); setPasswordOpen(false); + showToast({ type: "success", title: "Passwort geändert", description: response.data.username }); } catch (err) { - setPasswordError(getErrorMessage(err)); + const message = getErrorMessage(err); + setPasswordError(message); + showToast({ type: "error", title: "Passwort konnte nicht geändert werden", description: message }); } finally { setPending(false); } diff --git a/frontend/athena/app/users/page.tsx b/frontend/athena/app/users/page.tsx index f66a662..34ee775 100644 --- a/frontend/athena/app/users/page.tsx +++ b/frontend/athena/app/users/page.tsx @@ -5,6 +5,7 @@ import { Plus } from "lucide-react"; import ConfirmDialog from "@/components/common/ConfirmDialog"; import SearchInput from "@/components/common/SearchInput"; +import { useToast } from "@/components/common/ToastProvider"; import { Button } from "@/components/ui/button"; import UserFormDialog from "@/components/users/UserFormDialog"; import UserTable from "@/components/users/UserTable"; @@ -32,6 +33,7 @@ function getSortableValue(user: User, key: string) { } export default function UsersPage() { + const { showToast } = useToast(); const [users, setUsers] = useState([]); const [roles, setRoles] = useState([]); const [currentUser, setCurrentUser] = useState(null); @@ -159,14 +161,18 @@ export default function UsersPage() { setUsers((current) => current.map((user) => ( user.id === selectedUser.id ? response.data : user ))); + showToast({ type: "success", title: "Benutzer aktualisiert", description: response.data.username }); } else { const response = await api.post("/users", payload); setUsers((current) => [response.data, ...current]); + showToast({ type: "success", title: "Benutzer erstellt", description: response.data.username }); } setFormOpen(false); } catch (err) { - setFormError(getErrorMessage(err)); + const message = getErrorMessage(err); + setFormError(message); + showToast({ type: "error", title: "Benutzer konnte nicht gespeichert werden", description: message }); } finally { setPending(false); } @@ -183,9 +189,12 @@ export default function UsersPage() { try { await api.delete(`/users/${deleteUser.id}`); setUsers((current) => current.filter((user) => user.id !== deleteUser.id)); + showToast({ type: "success", title: "Benutzer gelöscht", description: deleteUser.username }); setDeleteUser(null); } catch (err) { - setDeleteError(getErrorMessage(err)); + const message = getErrorMessage(err); + setDeleteError(message); + showToast({ type: "error", title: "Benutzer konnte nicht gelöscht werden", description: message }); } finally { setPending(false); } diff --git a/frontend/athena/components/Sidebar.tsx b/frontend/athena/components/Sidebar.tsx index 6b93c90..7d2b98f 100644 --- a/frontend/athena/components/Sidebar.tsx +++ b/frontend/athena/components/Sidebar.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { + ClipboardList, FileText, LayoutDashboard, Package, @@ -24,6 +25,12 @@ const menu = [ href: "/dashboard", permission: "dashboard.read", }, + { + icon: ClipboardList, + name: "Audit Logs", + href: "/audit-logs", + permission: "audit_logs.read", + }, { icon: Users, name: "Benutzer", diff --git a/frontend/athena/components/common/ToastProvider.tsx b/frontend/athena/components/common/ToastProvider.tsx new file mode 100644 index 0000000..43b63a9 --- /dev/null +++ b/frontend/athena/components/common/ToastProvider.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; +import { CheckCircle2, Info, X, XCircle } from "lucide-react"; + +import { Button } from "@/components/ui/button"; + +type ToastType = "success" | "error" | "info"; + +type Toast = { + id: number; + type: ToastType; + title: string; + description?: string; +}; + +type ToastInput = Omit; + +type ToastContextValue = { + showToast: (toast: ToastInput) => void; +}; + +const ToastContext = createContext(null); + +const iconByType = { + success: CheckCircle2, + error: XCircle, + info: Info, +}; + +const colorByType = { + success: "border-emerald-200 bg-emerald-50 text-emerald-950", + error: "border-red-200 bg-red-50 text-red-950", + info: "border-slate-200 bg-white text-slate-950", +}; + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + + const dismissToast = useCallback((id: number) => { + setToasts((current) => current.filter((toast) => toast.id !== id)); + }, []); + + const showToast = useCallback((toast: ToastInput) => { + const id = Date.now() + Math.floor(Math.random() * 1000); + setToasts((current) => [...current, { ...toast, id }]); + window.setTimeout(() => dismissToast(id), 5000); + }, [dismissToast]); + + const value = useMemo(() => ({ showToast }), [showToast]); + + return ( + + {children} +
+ {toasts.map((toast) => { + const Icon = iconByType[toast.type]; + + return ( +
+
+ +
+

{toast.title}

+ {toast.description && ( +

{toast.description}

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

Kunden

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

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

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

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

Dashboard

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

{serverError}

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

{label}

+

{summary[key]}

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

{error}

; + if (!device) return

Gerät wird geladen...

; + + return ( +
+
+

{device.name}

+

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

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

{device.notes}

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

Keine verknüpften Dokumente.

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

{note.title}

+

{note.content}

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

Keine verknüpften Notizen.

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

Geräte

{rows.length} Modelle

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

{deleteItem.name}

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

{error}

; + if (!document) return

Dokument wird geladen...

; + + return ( +
+
+
+

{document.title}

+

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

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

{document.description}

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

Dokumente

{rows.length} Unterlagen

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

{deleteItem.title}

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

Hersteller

{rows.length} Einträge

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

{deleteItem.name}

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

Knowledge Notes

{rows.length} Reparatur- und Servicehinweise

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

{deleteItem.title}

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

Wissensdatenbank

+

Serviceunterlagen, Modelle, Schaltpläne und Reparaturwissen

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

{error}

} +
+ +
+
+

Letzte Dokumente

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

{document.title}

+

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

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

Noch keine Dokumente vorhanden.

} +
+
+
+

Letzte Notizen

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

{note.title}

+

{note.content}

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

Noch keine Notizen vorhanden.

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