Olympus/backend/hermes/app/services/knowledge_service.py
2026-07-03 18:11:58 +02:00

229 lines
11 KiB
Python

import re
import uuid
from fastapi import HTTPException, UploadFile, status
from sqlalchemy.orm import Session
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,
)
from app.storage import get_storage_service
from app.storage.exceptions import StorageFileNotFoundError, StorageValidationError
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 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
def storage_validation_error(exc: StorageValidationError) -> HTTPException:
detail = str(exc) or "Ungültige Datei"
status_code = status.HTTP_413_REQUEST_ENTITY_TOO_LARGE if "groß" in detail else status.HTTP_400_BAD_REQUEST
return HTTPException(status_code=status_code, detail=detail)
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)
storage_service = get_storage_service()
max_bytes = storage_service.max_upload_mb * 1024 * 1024
content = await file.read(max_bytes + 1)
original_name = file.filename or ""
mime_type = file.content_type
try:
metadata = storage_service.save_file(
namespace=f"knowledge/documents/{payload.manufacturer_id}",
content=content,
original_filename=original_name,
mime_type=mime_type,
)
except StorageValidationError as exc:
raise storage_validation_error(exc) from exc
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 ""),
file_name=metadata.original_filename,
file_path=metadata.storage_key,
mime_type=metadata.mime_type,
file_size=metadata.size,
checksum_sha256=metadata.checksum_sha256,
**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 open_document_file(document: KnowledgeDocument):
if not document.file_path:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument hat keine lokale Datei")
try:
return get_storage_service().open_file(document.file_path)
except StorageFileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht gefunden") from exc
except StorageValidationError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad") from exc
@staticmethod
def delete_document_file(document: KnowledgeDocument) -> None:
if not document.file_path:
return
KnowledgeService.delete_storage_key(document.file_path)
@staticmethod
def delete_storage_key(storage_key: str) -> None:
try:
get_storage_service().delete_file(storage_key)
except StorageValidationError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad") from exc
@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")