feat(knowledge): add service library

This commit is contained in:
DS | Schubert 2026-07-03 10:25:52 +02:00
parent ecab0fe6b6
commit 228da8f814
32 changed files with 2878 additions and 6 deletions

View file

@ -0,0 +1,336 @@
import logging
from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.rbac import require_permission
from app.db.database import get_db
from app.models.user import User
from app.repositories.knowledge_repository import KnowledgeRepository
from app.schemas.knowledge import (
DocumentType,
KnowledgeDeviceCreate,
KnowledgeDeviceResponse,
KnowledgeDeviceUpdate,
KnowledgeDocumentCreate,
KnowledgeDocumentResponse,
KnowledgeDocumentUpdate,
KnowledgeManufacturerCreate,
KnowledgeManufacturerResponse,
KnowledgeManufacturerUpdate,
KnowledgeNoteCreate,
KnowledgeNoteResponse,
KnowledgeNoteUpdate,
KnowledgeSearchResult,
NoteType,
normalize_tags,
)
from app.services.audit_service import sanitize, write_audit_log
from app.services.knowledge_service import KnowledgeService, assert_safe_path
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/knowledge", tags=["Knowledge"])
def get_manufacturer_or_404(db: Session, manufacturer_id: int):
manufacturer = KnowledgeRepository.get_manufacturer(db, manufacturer_id)
if manufacturer is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden")
return manufacturer
def get_device_or_404(db: Session, device_id: int):
device = KnowledgeRepository.get_device(db, device_id)
if device is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gerät nicht gefunden")
return device
def get_document_or_404(db: Session, document_id: int):
document = KnowledgeRepository.get_document(db, document_id)
if document is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden")
return document
def get_note_or_404(db: Session, note_id: int):
note = KnowledgeRepository.get_note(db, note_id)
if note is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Notiz nicht gefunden")
return note
def delete_or_conflict(db: Session, entity) -> None:
try:
db.delete(entity)
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Eintrag wird noch verwendet") from exc
@router.get("/manufacturers", response_model=list[KnowledgeManufacturerResponse])
def list_manufacturers(
q: str = "",
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.read")),
):
logger.info("knowledge.manufacturers.list", extra={"actor_user_id": current_user.id})
return KnowledgeRepository.list_manufacturers(db, q=q, limit=limit, offset=offset)
@router.get("/manufacturers/{manufacturer_id}", response_model=KnowledgeManufacturerResponse)
def get_manufacturer(manufacturer_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))):
logger.info("knowledge.manufacturers.detail", extra={"actor_user_id": current_user.id, "target_manufacturer_id": manufacturer_id})
return get_manufacturer_or_404(db, manufacturer_id)
@router.post("/manufacturers", response_model=KnowledgeManufacturerResponse, status_code=status.HTTP_201_CREATED)
def create_manufacturer(
payload: KnowledgeManufacturerCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.create")),
):
manufacturer = KnowledgeService.create_manufacturer(db, payload)
write_audit_log(db, action="knowledge.manufacturers.create", entity_type="knowledge_manufacturers", entity_id=manufacturer.id, entity_label=manufacturer.name, actor=current_user, request=request, after_data=manufacturer)
return manufacturer
@router.put("/manufacturers/{manufacturer_id}", response_model=KnowledgeManufacturerResponse)
def update_manufacturer(
manufacturer_id: int,
payload: KnowledgeManufacturerUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.update")),
):
manufacturer = get_manufacturer_or_404(db, manufacturer_id)
before_data = sanitize(manufacturer)
updated = KnowledgeService.update_manufacturer(db, manufacturer, payload)
write_audit_log(db, action="knowledge.manufacturers.update", entity_type="knowledge_manufacturers", entity_id=updated.id, entity_label=updated.name, actor=current_user, request=request, before_data=before_data, after_data=updated)
return updated
@router.delete("/manufacturers/{manufacturer_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_manufacturer(
manufacturer_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.delete")),
):
manufacturer = get_manufacturer_or_404(db, manufacturer_id)
before_data = sanitize(manufacturer)
label = manufacturer.name
delete_or_conflict(db, manufacturer)
write_audit_log(db, action="knowledge.manufacturers.delete", entity_type="knowledge_manufacturers", entity_id=manufacturer_id, entity_label=label, actor=current_user, request=request, before_data=before_data)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/devices", response_model=list[KnowledgeDeviceResponse])
def list_devices(
q: str = "",
manufacturer_id: int | None = None,
device_type: str = "",
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.read")),
):
logger.info("knowledge.devices.list", extra={"actor_user_id": current_user.id})
return KnowledgeRepository.list_devices(db, q=q, manufacturer_id=manufacturer_id, device_type=device_type, limit=limit, offset=offset)
@router.get("/devices/{device_id}", response_model=KnowledgeDeviceResponse)
def get_device(device_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))):
logger.info("knowledge.devices.detail", extra={"actor_user_id": current_user.id, "target_device_id": device_id})
return get_device_or_404(db, device_id)
@router.post("/devices", response_model=KnowledgeDeviceResponse, status_code=status.HTTP_201_CREATED)
def create_device(payload: KnowledgeDeviceCreate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.create"))):
device = KnowledgeService.create_device(db, payload)
write_audit_log(db, action="knowledge.devices.create", entity_type="knowledge_devices", entity_id=device.id, entity_label=device.name, actor=current_user, request=request, after_data=device)
return device
@router.put("/devices/{device_id}", response_model=KnowledgeDeviceResponse)
def update_device(device_id: int, payload: KnowledgeDeviceUpdate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.update"))):
device = get_device_or_404(db, device_id)
before_data = sanitize(device)
updated = KnowledgeService.update_device(db, device, payload)
write_audit_log(db, action="knowledge.devices.update", entity_type="knowledge_devices", entity_id=updated.id, entity_label=updated.name, actor=current_user, request=request, before_data=before_data, after_data=updated)
return updated
@router.delete("/devices/{device_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_device(device_id: int, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.delete"))):
device = get_device_or_404(db, device_id)
before_data = sanitize(device)
label = device.name
delete_or_conflict(db, device)
write_audit_log(db, action="knowledge.devices.delete", entity_type="knowledge_devices", entity_id=device_id, entity_label=label, actor=current_user, request=request, before_data=before_data)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/documents", response_model=list[KnowledgeDocumentResponse])
def list_documents(
q: str = "",
manufacturer_id: int | None = None,
device_id: int | None = None,
document_type: str = "",
language: str = "",
tag: str = "",
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.read")),
):
logger.info("knowledge.documents.list", extra={"actor_user_id": current_user.id})
return KnowledgeRepository.list_documents(db, q=q, manufacturer_id=manufacturer_id, device_id=device_id, document_type=document_type, language=language, tag=tag, limit=limit, offset=offset)
@router.get("/documents/{document_id:int}", response_model=KnowledgeDocumentResponse)
def get_document(document_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))):
logger.info("knowledge.documents.detail", extra={"actor_user_id": current_user.id, "target_document_id": document_id})
return get_document_or_404(db, document_id)
@router.post("/documents", response_model=KnowledgeDocumentResponse, status_code=status.HTTP_201_CREATED)
def create_document(payload: KnowledgeDocumentCreate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.create"))):
document = KnowledgeService.create_document(db, payload)
write_audit_log(db, action="knowledge.documents.create", entity_type="knowledge_documents", entity_id=document.id, entity_label=document.title, actor=current_user, request=request, after_data=document)
return document
@router.post("/documents/upload", response_model=KnowledgeDocumentResponse, status_code=status.HTTP_201_CREATED)
async def upload_document(
request: Request,
manufacturer_id: int = Form(...),
title: str = Form(...),
document_type: DocumentType = Form(...),
file: UploadFile = File(...),
device_id: int | None = Form(default=None),
language: str = Form(default="de"),
external_url: str = Form(default=""),
paperless_document_id: str = Form(default=""),
description: str = Form(default=""),
tags: str = Form(default=""),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.upload")),
):
payload = KnowledgeDocumentCreate(
manufacturer_id=manufacturer_id,
device_id=device_id,
title=title,
document_type=document_type,
language=language,
external_url=external_url or None,
paperless_document_id=paperless_document_id,
description=description,
tags=normalize_tags(tags),
)
document = await KnowledgeService.upload_document(db, file=file, payload=payload)
write_audit_log(db, action="knowledge.documents.upload", entity_type="knowledge_documents", entity_id=document.id, entity_label=document.title, actor=current_user, request=request, after_data={key: value for key, value in sanitize(document).items() if key != "file_path"})
return document
@router.put("/documents/{document_id:int}", response_model=KnowledgeDocumentResponse)
def update_document(document_id: int, payload: KnowledgeDocumentUpdate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.update"))):
document = get_document_or_404(db, document_id)
before_data = sanitize(document)
updated = KnowledgeService.update_document(db, document, payload)
write_audit_log(db, action="knowledge.documents.update", entity_type="knowledge_documents", entity_id=updated.id, entity_label=updated.title, actor=current_user, request=request, before_data=before_data, after_data=updated)
return updated
@router.delete("/documents/{document_id:int}", status_code=status.HTTP_204_NO_CONTENT)
def delete_document(document_id: int, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.delete"))):
document = get_document_or_404(db, document_id)
before_data = sanitize(document)
label = document.title
delete_or_conflict(db, document)
write_audit_log(db, action="knowledge.documents.delete", entity_type="knowledge_documents", entity_id=document_id, entity_label=label, actor=current_user, request=request, before_data=before_data)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/documents/{document_id:int}/download")
def download_document(document_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.download"))):
document = get_document_or_404(db, document_id)
if not document.file_path:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument hat keine lokale Datei")
path = assert_safe_path(Path(document.file_path))
if not path.exists() or not path.is_file():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht gefunden")
logger.info("knowledge.documents.download", extra={"actor_user_id": current_user.id, "target_document_id": document_id})
return FileResponse(path, media_type=document.mime_type or "application/octet-stream", filename=document.file_name)
@router.get("/notes", response_model=list[KnowledgeNoteResponse])
def list_notes(
q: str = "",
manufacturer_id: int | None = None,
device_id: int | None = None,
note_type: str = "",
tag: str = "",
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("knowledge.read")),
):
logger.info("knowledge.notes.list", extra={"actor_user_id": current_user.id})
return KnowledgeRepository.list_notes(db, q=q, manufacturer_id=manufacturer_id, device_id=device_id, note_type=note_type, tag=tag, limit=limit, offset=offset)
@router.get("/notes/{note_id}", response_model=KnowledgeNoteResponse)
def get_note(note_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))):
logger.info("knowledge.notes.detail", extra={"actor_user_id": current_user.id, "target_note_id": note_id})
return get_note_or_404(db, note_id)
@router.post("/notes", response_model=KnowledgeNoteResponse, status_code=status.HTTP_201_CREATED)
def create_note(payload: KnowledgeNoteCreate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.create"))):
note = KnowledgeService.create_note(db, payload)
write_audit_log(db, action="knowledge.notes.create", entity_type="knowledge_notes", entity_id=note.id, entity_label=note.title, actor=current_user, request=request, after_data=note)
return note
@router.put("/notes/{note_id}", response_model=KnowledgeNoteResponse)
def update_note(note_id: int, payload: KnowledgeNoteUpdate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.update"))):
note = get_note_or_404(db, note_id)
before_data = sanitize(note)
updated = KnowledgeService.update_note(db, note, payload)
write_audit_log(db, action="knowledge.notes.update", entity_type="knowledge_notes", entity_id=updated.id, entity_label=updated.title, actor=current_user, request=request, before_data=before_data, after_data=updated)
return updated
@router.delete("/notes/{note_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_note(note_id: int, request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.delete"))):
note = get_note_or_404(db, note_id)
before_data = sanitize(note)
label = note.title
delete_or_conflict(db, note)
write_audit_log(db, action="knowledge.notes.delete", entity_type="knowledge_notes", entity_id=note_id, entity_label=label, actor=current_user, request=request, before_data=before_data)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/search", response_model=list[KnowledgeSearchResult])
def search_knowledge(q: str = Query(..., min_length=1), db: Session = Depends(get_db), current_user: User = Depends(require_permission("knowledge.read"))):
logger.info("knowledge.search", extra={"actor_user_id": current_user.id})
manufacturers = KnowledgeRepository.list_manufacturers(db, q=q, limit=10, offset=0)
devices = KnowledgeRepository.list_devices(db, q=q, limit=10, offset=0)
documents = KnowledgeRepository.list_documents(db, q=q, limit=10, offset=0)
notes = KnowledgeRepository.list_notes(db, q=q, limit=10, offset=0)
results: list[KnowledgeSearchResult] = []
results.extend(KnowledgeSearchResult(entity_type="manufacturer", id=item.id, title=item.name, href=f"/knowledge/manufacturers?selected={item.id}") for item in manufacturers)
results.extend(KnowledgeSearchResult(entity_type="device", id=item.id, title=item.name, subtitle=item.model_number, href=f"/knowledge/devices/{item.id}") for item in devices)
results.extend(KnowledgeSearchResult(entity_type="document", id=item.id, title=item.title, subtitle=item.document_type, href=f"/knowledge/documents/{item.id}") for item in documents)
results.extend(KnowledgeSearchResult(entity_type="note", id=item.id, title=item.title, subtitle=item.note_type, href=f"/knowledge/notes?selected={item.id}") for item in notes)
return results[:30]

View file

@ -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",

View file

@ -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

View file

@ -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__)

View file

@ -0,0 +1,101 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.database import Base
class KnowledgeManufacturer(Base):
__tablename__ = "knowledge_manufacturers"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(160), unique=True, index=True)
slug: Mapped[str] = mapped_column(String(180), unique=True, index=True)
website: Mapped[str] = mapped_column(String(255), default="", server_default="")
notes: Mapped[str] = mapped_column(Text, default="", server_default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
devices: Mapped[list["KnowledgeDevice"]] = relationship(back_populates="manufacturer", cascade="all, delete-orphan")
documents: Mapped[list["KnowledgeDocument"]] = relationship(back_populates="manufacturer")
notes_rel: Mapped[list["KnowledgeNote"]] = relationship(back_populates="manufacturer")
class KnowledgeDevice(Base):
__tablename__ = "knowledge_devices"
__table_args__ = (
UniqueConstraint("manufacturer_id", "slug", name="uq_knowledge_devices_manufacturer_slug"),
)
id: Mapped[int] = mapped_column(primary_key=True)
manufacturer_id: Mapped[int] = mapped_column(ForeignKey("knowledge_manufacturers.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(180), index=True)
slug: Mapped[str] = mapped_column(String(200), index=True)
model_number: Mapped[str] = mapped_column(String(120), default="", server_default="", index=True)
device_type: Mapped[str] = mapped_column(String(80), default="", server_default="", index=True)
frequency_range: Mapped[str] = mapped_column(String(120), default="", server_default="")
production_year_from: Mapped[int | None] = mapped_column(Integer, nullable=True)
production_year_to: Mapped[int | None] = mapped_column(Integer, nullable=True)
notes: Mapped[str] = mapped_column(Text, default="", server_default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
manufacturer: Mapped[KnowledgeManufacturer] = relationship(back_populates="devices")
documents: Mapped[list["KnowledgeDocument"]] = relationship(back_populates="device")
notes_rel: Mapped[list["KnowledgeNote"]] = relationship(back_populates="device")
class KnowledgeDocument(Base):
__tablename__ = "knowledge_documents"
id: Mapped[int] = mapped_column(primary_key=True)
manufacturer_id: Mapped[int] = mapped_column(ForeignKey("knowledge_manufacturers.id", ondelete="RESTRICT"), index=True)
device_id: Mapped[int | None] = mapped_column(ForeignKey("knowledge_devices.id", ondelete="SET NULL"), nullable=True, index=True)
title: Mapped[str] = mapped_column(String(255), index=True)
slug: Mapped[str] = mapped_column(String(280), unique=True, index=True)
document_type: Mapped[str] = mapped_column(String(80), index=True)
language: Mapped[str] = mapped_column(String(20), default="de", server_default="", index=True)
file_name: Mapped[str] = mapped_column(String(255), default="", server_default="")
file_path: Mapped[str] = mapped_column(String(500), default="", server_default="")
mime_type: Mapped[str] = mapped_column(String(120), default="", server_default="")
file_size: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
checksum_sha256: Mapped[str] = mapped_column(String(64), default="", server_default="", index=True)
external_url: Mapped[str] = mapped_column(String(500), default="", server_default="")
paperless_document_id: Mapped[str] = mapped_column(String(120), default="", server_default="", index=True)
description: Mapped[str] = mapped_column(Text, default="", server_default="")
tags: Mapped[list[str]] = mapped_column(JSON, default=list, server_default="[]")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
manufacturer: Mapped[KnowledgeManufacturer] = relationship(back_populates="documents")
device: Mapped[KnowledgeDevice | None] = relationship(back_populates="documents")
class KnowledgeNote(Base):
__tablename__ = "knowledge_notes"
id: Mapped[int] = mapped_column(primary_key=True)
manufacturer_id: Mapped[int | None] = mapped_column(ForeignKey("knowledge_manufacturers.id", ondelete="SET NULL"), nullable=True, index=True)
device_id: Mapped[int | None] = mapped_column(ForeignKey("knowledge_devices.id", ondelete="SET NULL"), nullable=True, index=True)
title: Mapped[str] = mapped_column(String(255), index=True)
content: Mapped[str] = mapped_column(Text)
note_type: Mapped[str] = mapped_column(String(80), index=True)
severity: Mapped[str] = mapped_column(String(40), default="", server_default="", index=True)
tags: Mapped[list[str]] = mapped_column(JSON, default=list, server_default="[]")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
manufacturer: Mapped[KnowledgeManufacturer | None] = relationship(back_populates="notes_rel")
device: Mapped[KnowledgeDevice | None] = relationship(back_populates="notes_rel")
class KnowledgeCategory(Base):
__tablename__ = "knowledge_categories"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(120), unique=True, index=True)
slug: Mapped[str] = mapped_column(String(140), unique=True, index=True)
description: Mapped[str] = mapped_column(Text, default="", server_default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View file

@ -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",

View file

@ -0,0 +1,177 @@
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, joinedload
from app.models.knowledge import (
KnowledgeDevice,
KnowledgeDocument,
KnowledgeManufacturer,
KnowledgeNote,
)
def paginate(query, limit: int, offset: int):
return query.limit(max(1, min(limit, 100))).offset(max(0, offset))
class KnowledgeRepository:
@staticmethod
def list_manufacturers(db: Session, *, q: str = "", limit: int = 50, offset: int = 0) -> list[KnowledgeManufacturer]:
query = select(KnowledgeManufacturer).order_by(KnowledgeManufacturer.name)
if q:
pattern = f"%{q.lower()}%"
query = query.where(func.lower(KnowledgeManufacturer.name).like(pattern))
return list(db.scalars(paginate(query, limit, offset)))
@staticmethod
def get_manufacturer(db: Session, manufacturer_id: int) -> KnowledgeManufacturer | None:
return db.get(KnowledgeManufacturer, manufacturer_id)
@staticmethod
def get_manufacturer_by_name(db: Session, name: str, exclude_id: int | None = None) -> KnowledgeManufacturer | None:
query = select(KnowledgeManufacturer).where(func.lower(KnowledgeManufacturer.name) == name.lower())
if exclude_id is not None:
query = query.where(KnowledgeManufacturer.id != exclude_id)
return db.scalar(query)
@staticmethod
def get_manufacturer_by_slug(db: Session, slug: str, exclude_id: int | None = None) -> KnowledgeManufacturer | None:
query = select(KnowledgeManufacturer).where(KnowledgeManufacturer.slug == slug)
if exclude_id is not None:
query = query.where(KnowledgeManufacturer.id != exclude_id)
return db.scalar(query)
@staticmethod
def list_devices(
db: Session,
*,
q: str = "",
manufacturer_id: int | None = None,
device_type: str = "",
limit: int = 50,
offset: int = 0,
) -> list[KnowledgeDevice]:
query = select(KnowledgeDevice).options(joinedload(KnowledgeDevice.manufacturer)).order_by(KnowledgeDevice.name)
if manufacturer_id is not None:
query = query.where(KnowledgeDevice.manufacturer_id == manufacturer_id)
if device_type:
query = query.where(KnowledgeDevice.device_type == device_type)
if q:
pattern = f"%{q.lower()}%"
query = query.where(
or_(
func.lower(KnowledgeDevice.name).like(pattern),
func.lower(KnowledgeDevice.model_number).like(pattern),
)
)
return list(db.scalars(paginate(query, limit, offset)))
@staticmethod
def get_device(db: Session, device_id: int) -> KnowledgeDevice | None:
return db.scalar(
select(KnowledgeDevice)
.where(KnowledgeDevice.id == device_id)
.options(joinedload(KnowledgeDevice.manufacturer))
)
@staticmethod
def get_device_by_slug(
db: Session,
*,
manufacturer_id: int,
slug: str,
exclude_id: int | None = None,
) -> KnowledgeDevice | None:
query = select(KnowledgeDevice).where(KnowledgeDevice.manufacturer_id == manufacturer_id, KnowledgeDevice.slug == slug)
if exclude_id is not None:
query = query.where(KnowledgeDevice.id != exclude_id)
return db.scalar(query)
@staticmethod
def list_documents(
db: Session,
*,
q: str = "",
manufacturer_id: int | None = None,
device_id: int | None = None,
document_type: str = "",
language: str = "",
tag: str = "",
limit: int = 50,
offset: int = 0,
) -> list[KnowledgeDocument]:
query = (
select(KnowledgeDocument)
.options(joinedload(KnowledgeDocument.manufacturer), joinedload(KnowledgeDocument.device).joinedload(KnowledgeDevice.manufacturer))
.order_by(KnowledgeDocument.created_at.desc())
)
if manufacturer_id is not None:
query = query.where(KnowledgeDocument.manufacturer_id == manufacturer_id)
if device_id is not None:
query = query.where(KnowledgeDocument.device_id == device_id)
if document_type:
query = query.where(KnowledgeDocument.document_type == document_type)
if language:
query = query.where(KnowledgeDocument.language == language)
if q:
pattern = f"%{q.lower()}%"
query = query.where(func.lower(KnowledgeDocument.title).like(pattern))
documents = list(db.scalars(paginate(query, limit, offset)))
if tag:
tag_lower = tag.lower()
documents = [document for document in documents if any(item.lower() == tag_lower for item in document.tags)]
return documents
@staticmethod
def get_document(db: Session, document_id: int) -> KnowledgeDocument | None:
return db.scalar(
select(KnowledgeDocument)
.where(KnowledgeDocument.id == document_id)
.options(joinedload(KnowledgeDocument.manufacturer), joinedload(KnowledgeDocument.device).joinedload(KnowledgeDevice.manufacturer))
)
@staticmethod
def get_document_by_slug(db: Session, slug: str, exclude_id: int | None = None) -> KnowledgeDocument | None:
query = select(KnowledgeDocument).where(KnowledgeDocument.slug == slug)
if exclude_id is not None:
query = query.where(KnowledgeDocument.id != exclude_id)
return db.scalar(query)
@staticmethod
def list_notes(
db: Session,
*,
q: str = "",
manufacturer_id: int | None = None,
device_id: int | None = None,
note_type: str = "",
tag: str = "",
limit: int = 50,
offset: int = 0,
) -> list[KnowledgeNote]:
query = (
select(KnowledgeNote)
.options(joinedload(KnowledgeNote.manufacturer), joinedload(KnowledgeNote.device).joinedload(KnowledgeDevice.manufacturer))
.order_by(KnowledgeNote.created_at.desc())
)
if manufacturer_id is not None:
query = query.where(KnowledgeNote.manufacturer_id == manufacturer_id)
if device_id is not None:
query = query.where(KnowledgeNote.device_id == device_id)
if note_type:
query = query.where(KnowledgeNote.note_type == note_type)
if q:
pattern = f"%{q.lower()}%"
query = query.where(or_(func.lower(KnowledgeNote.title).like(pattern), func.lower(KnowledgeNote.content).like(pattern)))
notes = list(db.scalars(paginate(query, limit, offset)))
if tag:
tag_lower = tag.lower()
notes = [note for note in notes if any(item.lower() == tag_lower for item in note.tags)]
return notes
@staticmethod
def get_note(db: Session, note_id: int) -> KnowledgeNote | None:
return db.scalar(
select(KnowledgeNote)
.where(KnowledgeNote.id == note_id)
.options(joinedload(KnowledgeNote.manufacturer), joinedload(KnowledgeNote.device).joinedload(KnowledgeDevice.manufacturer))
)

View file

@ -0,0 +1,200 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator
DocumentType = Literal[
"manual",
"service_manual",
"schematic",
"alignment",
"parts_list",
"firmware",
"datasheet",
"service_bulletin",
"other",
]
NoteType = Literal["repair", "known_fault", "alignment", "spare_part", "general"]
NoteSeverity = Literal["low", "medium", "high", "critical", ""]
def normalize_text(value: object) -> str:
if value is None:
return ""
return str(value).strip()
def normalize_tags(value: object) -> list[str]:
if value is None or value == "":
return []
if isinstance(value, str):
parts = value.split(",")
elif isinstance(value, list):
parts = value
else:
parts = []
seen: set[str] = set()
tags: list[str] = []
for part in parts:
tag = str(part).strip()
key = tag.lower()
if tag and key not in seen:
seen.add(key)
tags.append(tag)
return tags
class KnowledgeManufacturerBase(BaseModel):
name: str = Field(min_length=2, max_length=160)
website: HttpUrl | None = None
notes: str = ""
@field_validator("name", "notes", mode="before")
@classmethod
def normalize_text_fields(cls, value: object) -> str:
return normalize_text(value)
class KnowledgeManufacturerCreate(KnowledgeManufacturerBase):
pass
class KnowledgeManufacturerUpdate(KnowledgeManufacturerBase):
pass
class KnowledgeManufacturerResponse(KnowledgeManufacturerBase):
id: int
slug: str
website: str
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class KnowledgeDeviceBase(BaseModel):
manufacturer_id: int
name: str = Field(min_length=2, max_length=180)
model_number: str = Field(default="", max_length=120)
device_type: str = Field(default="", max_length=80)
frequency_range: str = Field(default="", max_length=120)
production_year_from: int | None = Field(default=None, ge=1900, le=2100)
production_year_to: int | None = Field(default=None, ge=1900, le=2100)
notes: str = ""
@field_validator("name", "model_number", "device_type", "frequency_range", "notes", mode="before")
@classmethod
def normalize_text_fields(cls, value: object) -> str:
return normalize_text(value)
class KnowledgeDeviceCreate(KnowledgeDeviceBase):
pass
class KnowledgeDeviceUpdate(KnowledgeDeviceBase):
pass
class KnowledgeDeviceResponse(KnowledgeDeviceBase):
id: int
slug: str
created_at: datetime
updated_at: datetime
manufacturer: KnowledgeManufacturerResponse
model_config = ConfigDict(from_attributes=True)
class KnowledgeDocumentBase(BaseModel):
manufacturer_id: int
device_id: int | None = None
title: str = Field(min_length=2, max_length=255)
document_type: DocumentType
language: str = Field(default="de", min_length=2, max_length=20)
external_url: HttpUrl | None = None
paperless_document_id: str = Field(default="", max_length=120)
description: str = ""
tags: list[str] = Field(default_factory=list)
@field_validator("title", "language", "paperless_document_id", "description", mode="before")
@classmethod
def normalize_text_fields(cls, value: object) -> str:
return normalize_text(value)
@field_validator("tags", mode="before")
@classmethod
def normalize_tag_fields(cls, value: object) -> list[str]:
return normalize_tags(value)
class KnowledgeDocumentCreate(KnowledgeDocumentBase):
pass
class KnowledgeDocumentUpdate(KnowledgeDocumentBase):
pass
class KnowledgeDocumentResponse(KnowledgeDocumentBase):
id: int
slug: str
external_url: str
file_name: str
file_path: str
mime_type: str
file_size: int
checksum_sha256: str
created_at: datetime
updated_at: datetime
manufacturer: KnowledgeManufacturerResponse
device: KnowledgeDeviceResponse | None = None
model_config = ConfigDict(from_attributes=True)
class KnowledgeNoteBase(BaseModel):
manufacturer_id: int | None = None
device_id: int | None = None
title: str = Field(min_length=2, max_length=255)
content: str = Field(min_length=1)
note_type: NoteType = "general"
severity: NoteSeverity = ""
tags: list[str] = Field(default_factory=list)
@field_validator("title", "content", "severity", mode="before")
@classmethod
def normalize_text_fields(cls, value: object) -> str:
return normalize_text(value)
@field_validator("tags", mode="before")
@classmethod
def normalize_tag_fields(cls, value: object) -> list[str]:
return normalize_tags(value)
class KnowledgeNoteCreate(KnowledgeNoteBase):
pass
class KnowledgeNoteUpdate(KnowledgeNoteBase):
pass
class KnowledgeNoteResponse(KnowledgeNoteBase):
id: int
created_at: datetime
updated_at: datetime
manufacturer: KnowledgeManufacturerResponse | None = None
device: KnowledgeDeviceResponse | None = None
model_config = ConfigDict(from_attributes=True)
class KnowledgeSearchResult(BaseModel):
entity_type: str
id: int
title: str
subtitle: str = ""
href: str

View file

@ -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)

View file

@ -0,0 +1,247 @@
import hashlib
import mimetypes
import re
import uuid
from pathlib import Path
from fastapi import HTTPException, UploadFile, status
from sqlalchemy.orm import Session
from app.core.config import settings
from app.models.knowledge import KnowledgeDevice, KnowledgeDocument, KnowledgeManufacturer, KnowledgeNote
from app.repositories.knowledge_repository import KnowledgeRepository
from app.schemas.knowledge import (
KnowledgeDeviceCreate,
KnowledgeDeviceUpdate,
KnowledgeDocumentCreate,
KnowledgeDocumentUpdate,
KnowledgeManufacturerCreate,
KnowledgeManufacturerUpdate,
KnowledgeNoteCreate,
KnowledgeNoteUpdate,
)
ALLOWED_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".txt", ".zip"}
ALLOWED_MIME_TYPES = {
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
"text/plain",
"application/zip",
"application/x-zip-compressed",
}
def slugify(value: str) -> str:
normalized = value.strip().lower()
normalized = normalized.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
normalized = re.sub(r"[^a-z0-9]+", "-", normalized)
normalized = normalized.strip("-")
return normalized or uuid.uuid4().hex[:10]
def unique_slug(db: Session, base: str, exists) -> str:
slug = slugify(base)
candidate = slug
index = 2
while exists(db, candidate):
candidate = f"{slug}-{index}"
index += 1
return candidate
def storage_root() -> Path:
root = Path(settings.knowledge_storage_path).resolve()
root.mkdir(parents=True, exist_ok=True)
return root
def assert_safe_path(path: Path) -> Path:
root = storage_root()
resolved = path.resolve()
if root != resolved and root not in resolved.parents:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad")
return resolved
def safe_file_name(file_name: str) -> str:
name = Path(file_name).name.strip()
stem = slugify(Path(name).stem)
suffix = Path(name).suffix.lower()
return f"{stem}{suffix}" if suffix else stem
def parse_tags(value: str) -> list[str]:
seen: set[str] = set()
tags: list[str] = []
for item in value.split(","):
tag = item.strip()
key = tag.lower()
if tag and key not in seen:
seen.add(key)
tags.append(tag)
return tags
async def read_upload(file: UploadFile) -> tuple[bytes, str, str]:
original_name = file.filename or ""
file_name = safe_file_name(original_name)
extension = Path(file_name).suffix.lower()
if extension not in ALLOWED_EXTENSIONS:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Dateityp ist nicht erlaubt")
max_bytes = max(1, settings.knowledge_max_upload_mb) * 1024 * 1024
content = await file.read(max_bytes + 1)
if not content:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Upload-Datei ist leer")
if len(content) > max_bytes:
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="Upload-Datei ist zu groß")
mime_type = file.content_type or mimetypes.guess_type(file_name)[0] or "application/octet-stream"
if mime_type not in ALLOWED_MIME_TYPES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="MIME-Type ist nicht erlaubt")
return content, file_name, mime_type
class KnowledgeService:
@staticmethod
def create_manufacturer(db: Session, payload: KnowledgeManufacturerCreate) -> KnowledgeManufacturer:
if KnowledgeRepository.get_manufacturer_by_name(db, payload.name):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Herstellername ist bereits vergeben")
slug = unique_slug(db, payload.name, lambda session, value: KnowledgeRepository.get_manufacturer_by_slug(session, value) is not None)
manufacturer = KnowledgeManufacturer(name=payload.name, slug=slug, website=str(payload.website or ""), notes=payload.notes)
db.add(manufacturer)
db.commit()
db.refresh(manufacturer)
return manufacturer
@staticmethod
def update_manufacturer(db: Session, manufacturer: KnowledgeManufacturer, payload: KnowledgeManufacturerUpdate) -> KnowledgeManufacturer:
if KnowledgeRepository.get_manufacturer_by_name(db, payload.name, manufacturer.id):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Herstellername ist bereits vergeben")
manufacturer.name = payload.name
manufacturer.slug = unique_slug(
db,
payload.name,
lambda session, value: KnowledgeRepository.get_manufacturer_by_slug(session, value, manufacturer.id) is not None,
)
manufacturer.website = str(payload.website or "")
manufacturer.notes = payload.notes
db.commit()
db.refresh(manufacturer)
return manufacturer
@staticmethod
def create_device(db: Session, payload: KnowledgeDeviceCreate) -> KnowledgeDevice:
if KnowledgeRepository.get_manufacturer(db, payload.manufacturer_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden")
slug = slugify(payload.model_number or payload.name)
if KnowledgeRepository.get_device_by_slug(db, manufacturer_id=payload.manufacturer_id, slug=slug):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Geräte-Slug ist für diesen Hersteller bereits vergeben")
device = KnowledgeDevice(slug=slug, **payload.model_dump())
db.add(device)
db.commit()
return KnowledgeRepository.get_device(db, device.id) or device
@staticmethod
def update_device(db: Session, device: KnowledgeDevice, payload: KnowledgeDeviceUpdate) -> KnowledgeDevice:
if KnowledgeRepository.get_manufacturer(db, payload.manufacturer_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden")
slug = slugify(payload.model_number or payload.name)
if KnowledgeRepository.get_device_by_slug(db, manufacturer_id=payload.manufacturer_id, slug=slug, exclude_id=device.id):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Geräte-Slug ist für diesen Hersteller bereits vergeben")
for key, value in payload.model_dump().items():
setattr(device, key, value)
device.slug = slug
db.commit()
return KnowledgeRepository.get_device(db, device.id) or device
@staticmethod
def create_document(db: Session, payload: KnowledgeDocumentCreate) -> KnowledgeDocument:
KnowledgeService._validate_document_links(db, payload.manufacturer_id, payload.device_id)
slug = unique_slug(db, payload.title, lambda session, value: KnowledgeRepository.get_document_by_slug(session, value) is not None)
document = KnowledgeDocument(slug=slug, external_url=str(payload.external_url or ""), **payload.model_dump(exclude={"external_url"}))
db.add(document)
db.commit()
return KnowledgeRepository.get_document(db, document.id) or document
@staticmethod
async def upload_document(
db: Session,
*,
file: UploadFile,
payload: KnowledgeDocumentCreate,
) -> KnowledgeDocument:
KnowledgeService._validate_document_links(db, payload.manufacturer_id, payload.device_id)
content, file_name, mime_type = await read_upload(file)
checksum = hashlib.sha256(content).hexdigest()
slug = unique_slug(db, payload.title, lambda session, value: KnowledgeRepository.get_document_by_slug(session, value) is not None)
target_dir = storage_root() / str(payload.manufacturer_id)
target_dir.mkdir(parents=True, exist_ok=True)
stored_name = f"{uuid.uuid4().hex}-{file_name}"
file_path = assert_safe_path(target_dir / stored_name)
file_path.write_bytes(content)
document = KnowledgeDocument(
slug=slug,
external_url=str(payload.external_url or ""),
file_name=file_name,
file_path=str(file_path),
mime_type=mime_type,
file_size=len(content),
checksum_sha256=checksum,
**payload.model_dump(exclude={"external_url"}),
)
db.add(document)
db.commit()
return KnowledgeRepository.get_document(db, document.id) or document
@staticmethod
def update_document(db: Session, document: KnowledgeDocument, payload: KnowledgeDocumentUpdate) -> KnowledgeDocument:
KnowledgeService._validate_document_links(db, payload.manufacturer_id, payload.device_id)
slug = unique_slug(db, payload.title, lambda session, value: KnowledgeRepository.get_document_by_slug(session, value, document.id) is not None)
for key, value in payload.model_dump(exclude={"external_url"}).items():
setattr(document, key, value)
document.external_url = str(payload.external_url or "")
document.slug = slug
db.commit()
return KnowledgeRepository.get_document(db, document.id) or document
@staticmethod
def create_note(db: Session, payload: KnowledgeNoteCreate) -> KnowledgeNote:
KnowledgeService._validate_optional_links(db, payload.manufacturer_id, payload.device_id)
note = KnowledgeNote(**payload.model_dump())
db.add(note)
db.commit()
return KnowledgeRepository.get_note(db, note.id) or note
@staticmethod
def update_note(db: Session, note: KnowledgeNote, payload: KnowledgeNoteUpdate) -> KnowledgeNote:
KnowledgeService._validate_optional_links(db, payload.manufacturer_id, payload.device_id)
for key, value in payload.model_dump().items():
setattr(note, key, value)
db.commit()
return KnowledgeRepository.get_note(db, note.id) or note
@staticmethod
def _validate_document_links(db: Session, manufacturer_id: int, device_id: int | None) -> None:
if KnowledgeRepository.get_manufacturer(db, manufacturer_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden")
if device_id is not None:
device = KnowledgeRepository.get_device(db, device_id)
if device is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gerät nicht gefunden")
if device.manufacturer_id != manufacturer_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Gerät gehört nicht zum Hersteller")
@staticmethod
def _validate_optional_links(db: Session, manufacturer_id: int | None, device_id: int | None) -> None:
if manufacturer_id is not None and KnowledgeRepository.get_manufacturer(db, manufacturer_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hersteller nicht gefunden")
if device_id is not None:
device = KnowledgeRepository.get_device(db, device_id)
if device is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gerät nicht gefunden")
if manufacturer_id is not None and device.manufacturer_id != manufacturer_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Gerät gehört nicht zum Hersteller")