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

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