feat(storage): add local storage framework
This commit is contained in:
parent
228da8f814
commit
964b545bc5
24 changed files with 890 additions and 98 deletions
|
|
@ -130,6 +130,19 @@ def action_title(action: str) -> str:
|
|||
"customer_contacts.create": "Ansprechpartner erstellt",
|
||||
"customer_contacts.update": "Ansprechpartner bearbeitet",
|
||||
"customer_contacts.delete": "Ansprechpartner gelöscht",
|
||||
"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": "Notiz erstellt",
|
||||
"knowledge.notes.update": "Notiz bearbeitet",
|
||||
"knowledge.notes.delete": "Notiz gelöscht",
|
||||
"users.initial_admin_bootstrap": "Initialer Administrator erstellt",
|
||||
"knowledge.manufacturers.create": "Hersteller erstellt",
|
||||
"knowledge.manufacturers.update": "Hersteller bearbeitet",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
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 (
|
||||
|
|
@ -20,17 +16,8 @@ from app.schemas.knowledge import (
|
|||
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",
|
||||
}
|
||||
from app.storage import get_storage_service
|
||||
from app.storage.exceptions import StorageFileNotFoundError, StorageValidationError
|
||||
|
||||
|
||||
def slugify(value: str) -> str:
|
||||
|
|
@ -51,27 +38,6 @@ def unique_slug(db: Session, base: str, exists) -> str:
|
|||
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] = []
|
||||
|
|
@ -84,25 +50,10 @@ def parse_tags(value: str) -> list[str]:
|
|||
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
|
||||
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:
|
||||
|
|
@ -175,22 +126,29 @@ class KnowledgeService:
|
|||
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()
|
||||
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)
|
||||
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,
|
||||
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)
|
||||
|
|
@ -216,6 +174,30 @@ class KnowledgeService:
|
|||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue