feat(knowledge): add service library
This commit is contained in:
parent
ecab0fe6b6
commit
228da8f814
32 changed files with 2878 additions and 6 deletions
336
backend/hermes/app/api/knowledge.py
Normal file
336
backend/hermes/app/api/knowledge.py
Normal 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]
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
|
|||
101
backend/hermes/app/models/knowledge.py
Normal file
101
backend/hermes/app/models/knowledge.py
Normal 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())
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
177
backend/hermes/app/repositories/knowledge_repository.py
Normal file
177
backend/hermes/app/repositories/knowledge_repository.py
Normal 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))
|
||||
)
|
||||
200
backend/hermes/app/schemas/knowledge.py
Normal file
200
backend/hermes/app/schemas/knowledge.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
247
backend/hermes/app/services/knowledge_service.py
Normal file
247
backend/hermes/app/services/knowledge_service.py
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue