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_upload_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 not manufacturer_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Bitte wählen Sie einen Hersteller aus.") 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") @staticmethod def _validate_upload_links(db: Session, manufacturer_id: int, device_id: int | None) -> None: if not manufacturer_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Bitte wählen Sie einen Hersteller aus.") if device_id is None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Bitte wählen Sie ein Gerät aus.") manufacturer = KnowledgeRepository.get_manufacturer(db, manufacturer_id) if manufacturer is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Der ausgewählte Hersteller existiert nicht.") device = KnowledgeRepository.get_device(db, device_id) if device is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Das ausgewählte Gerät existiert nicht.") if device.manufacturer_id != manufacturer_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Das ausgewählte Gerät gehört nicht zum ausgewählten Hersteller.", )