Close the remaining places where an extension point was hardcoded, and expose a stable public import surface for every app. - files: register_storage_backend(...) / STORAGE_BACKENDS[name]["CLASS"]; unknown backends now fail loudly instead of silently using local - files: PipelineStepRegistry + @pipeline_step; PIPELINE_EXECUTOR setting - billing: INVOICE_PDF_BUILDER setting - security: TWO_FACTOR_SERVICE / TWO_FACTOR_RECOVERY_SERVICE settings - all app packages expose lazy public exports (PEP 562); infrasynth.shared re-exports its primitives eagerly - README documents the per-module extension-point table - tests/test_extensibility.py pins each hook plus the public surface
204 lines
8.3 KiB
Python
204 lines
8.3 KiB
Python
import hashlib
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
from django.http import FileResponse, HttpResponse, HttpResponseBase, HttpResponseRedirect
|
|
|
|
from infrasynth.shared.settings_utils import get_setting
|
|
|
|
from .models import FileCategory, PipelineExecution, ProcessingPipeline, StoredFile
|
|
from .signals import file_deleted, file_uploaded
|
|
from .storage import get_storage_backend, save_file
|
|
|
|
|
|
class FileService:
|
|
"""Public API for file management. Used by App B and other base apps."""
|
|
|
|
def upload(
|
|
self,
|
|
file_obj,
|
|
*,
|
|
filename: str,
|
|
category_slug: str | None = None,
|
|
user=None,
|
|
is_public: bool = False,
|
|
metadata: dict | None = None,
|
|
pipeline_slug: str | None = None,
|
|
) -> StoredFile:
|
|
"""Uploads a file to the configured storage backend. Returns the StoredFile."""
|
|
category = self._resolve_category(category_slug)
|
|
self._validate_file(file_obj, filename, category)
|
|
|
|
content = file_obj.read()
|
|
file_obj.seek(0)
|
|
checksum = hashlib.sha256(content).hexdigest()
|
|
size_bytes = getattr(file_obj, "size", None) or len(content)
|
|
mime_type = getattr(file_obj, "content_type", None) or "application/octet-stream"
|
|
|
|
backend_name = (
|
|
category.storage_backend_override
|
|
if category and category.storage_backend_override
|
|
else get_setting("INFRASYNTH_FILES", "DEFAULT_STORAGE_BACKEND", "local")
|
|
)
|
|
storage_key = self._build_storage_key(filename, category)
|
|
|
|
saved_key = save_file(file_obj, storage_key, backend=backend_name)
|
|
|
|
stored_file = StoredFile.objects.create(
|
|
storage_backend=backend_name,
|
|
storage_key=saved_key,
|
|
original_filename=filename,
|
|
mime_type=mime_type,
|
|
size_bytes=size_bytes,
|
|
checksum_sha256=checksum,
|
|
is_public=is_public,
|
|
category=category,
|
|
metadata=metadata or {},
|
|
uploaded_by=user,
|
|
)
|
|
|
|
if pipeline_slug:
|
|
self._schedule_pipeline(stored_file, pipeline_slug)
|
|
|
|
file_uploaded.send(
|
|
sender=StoredFile,
|
|
file_id=stored_file.id,
|
|
storage_key=stored_file.storage_key,
|
|
filename=stored_file.original_filename,
|
|
size=stored_file.size_bytes,
|
|
uploaded_by=user,
|
|
)
|
|
return stored_file
|
|
|
|
def get_signed_url(self, file_or_id, *, expiry_seconds: int = 3600) -> str:
|
|
"""Generates a temporary signed URL for direct download from storage."""
|
|
stored = self._resolve_file(file_or_id)
|
|
backend = get_storage_backend(stored.storage_backend)
|
|
return backend.generate_signed_url(stored.storage_key, expiry_seconds)
|
|
|
|
def get_download_response(self, file_or_id, request) -> HttpResponseBase:
|
|
"""Returns FileResponse, redirect to signed URL, or X-Sendfile response."""
|
|
stored = self._resolve_file(file_or_id)
|
|
backend = get_storage_backend(stored.storage_backend)
|
|
enable_x_sendfile = get_setting("INFRASYNTH_FILES", "ENABLE_X_SENDFILE", False)
|
|
|
|
if enable_x_sendfile:
|
|
response = HttpResponse()
|
|
response["X-Sendfile"] = stored.storage_key
|
|
response["Content-Type"] = stored.mime_type
|
|
response["Content-Disposition"] = f'attachment; filename="{stored.original_filename}"'
|
|
return response
|
|
|
|
if stored.storage_backend.lower() == "local":
|
|
fh = backend.open(stored.storage_key, "rb")
|
|
return FileResponse(
|
|
fh,
|
|
as_attachment=True,
|
|
filename=stored.original_filename,
|
|
content_type=stored.mime_type,
|
|
)
|
|
|
|
signed_url = backend.generate_signed_url(
|
|
stored.storage_key,
|
|
get_setting("INFRASYNTH_FILES", "SIGNED_URL_EXPIRY_SECONDS", 3600),
|
|
)
|
|
return HttpResponseRedirect(signed_url)
|
|
|
|
def delete(self, file_or_id, *, soft: bool = True) -> bool:
|
|
"""Deletes a file. soft=True marks it as deleted; soft=False removes from storage."""
|
|
stored = self._resolve_file(file_or_id)
|
|
if soft:
|
|
metadata = dict(stored.metadata or {})
|
|
metadata["is_deleted"] = True
|
|
stored.metadata = metadata
|
|
stored.save(update_fields=["metadata"])
|
|
else:
|
|
backend = get_storage_backend(stored.storage_backend)
|
|
if backend.exists(stored.storage_key):
|
|
backend.delete(stored.storage_key)
|
|
stored.delete()
|
|
|
|
file_deleted.send(
|
|
sender=StoredFile,
|
|
file_id=stored.id,
|
|
storage_key=stored.storage_key,
|
|
deleted_by=None,
|
|
)
|
|
return True
|
|
|
|
def get_file_info(self, file_or_id) -> dict:
|
|
"""Complete metadata of the file."""
|
|
stored = self._resolve_file(file_or_id)
|
|
return {
|
|
"id": stored.id,
|
|
"storage_backend": stored.storage_backend,
|
|
"storage_key": stored.storage_key,
|
|
"original_filename": stored.original_filename,
|
|
"mime_type": stored.mime_type,
|
|
"size_bytes": stored.size_bytes,
|
|
"checksum_sha256": stored.checksum_sha256,
|
|
"is_public": stored.is_public,
|
|
"category": ({"slug": stored.category.slug, "name": stored.category.name} if stored.category else None),
|
|
"metadata": stored.metadata,
|
|
"uploaded_by": (
|
|
{"id": stored.uploaded_by.id, "username": stored.uploaded_by.username} if stored.uploaded_by else None
|
|
),
|
|
"created_at": stored.created_at.isoformat() if stored.created_at else None,
|
|
}
|
|
|
|
def _resolve_file(self, file_or_id) -> StoredFile:
|
|
if isinstance(file_or_id, StoredFile):
|
|
return file_or_id
|
|
return StoredFile.objects.get(pk=file_or_id)
|
|
|
|
def _resolve_category(self, category_slug: str | None) -> FileCategory | None:
|
|
if not category_slug:
|
|
return None
|
|
category = FileCategory.objects.filter(slug=category_slug).first()
|
|
if not category or not category.is_active:
|
|
raise ValueError(f"Category '{category_slug}' not found or inactive.")
|
|
return category
|
|
|
|
def _validate_file(self, file_obj, filename: str, category: FileCategory | None) -> None:
|
|
max_mb = int(get_setting("INFRASYNTH_FILES", "MAX_UPLOAD_SIZE_MB", 100) or 0)
|
|
size = getattr(file_obj, "size", None)
|
|
if max_mb and size and size > max_mb * 1024 * 1024:
|
|
raise ValueError(f"File exceeds the global max upload size of {max_mb} MB.")
|
|
if not category:
|
|
return
|
|
ext = Path(filename).suffix.lstrip(".").lower()
|
|
allowed = [e.strip().lower() for e in (category.allowed_extensions or "").split(",") if e.strip()]
|
|
if allowed and ext not in allowed:
|
|
raise ValueError(f"Extension '.{ext}' not allowed. Allowed: {', '.join(allowed)}")
|
|
if category.max_size_bytes:
|
|
if size and size > category.max_size_bytes:
|
|
raise ValueError(f"File exceeds max size of {category.max_size_bytes} bytes.")
|
|
|
|
def _build_storage_key(self, filename: str, category: FileCategory | None) -> str:
|
|
prefix = (category.storage_path if category else "").strip("/")
|
|
safe_name = Path(filename).name
|
|
unique_dir = uuid.uuid4().hex
|
|
if prefix:
|
|
return f"{prefix}/{unique_dir}/{safe_name}"
|
|
return f"{unique_dir}/{safe_name}"
|
|
|
|
def _schedule_pipeline(self, stored_file: StoredFile, pipeline_slug: str) -> None:
|
|
if not get_setting("INFRASYNTH_FILES", "ENABLE_PROCESSING_PIPELINES", True):
|
|
return
|
|
pipeline = ProcessingPipeline.objects.filter(slug=pipeline_slug, is_active=True).first()
|
|
if not pipeline:
|
|
return
|
|
execution = PipelineExecution.objects.create(
|
|
file=stored_file,
|
|
pipeline=pipeline,
|
|
status=PipelineExecution.Status.PENDING,
|
|
)
|
|
backend = get_setting("INFRASYNTH_FILES", "PROCESSING_BACKEND", "celery")
|
|
if backend == "sync":
|
|
from .processing import get_pipeline_executor
|
|
|
|
get_pipeline_executor().execute(execution)
|
|
else:
|
|
from .processing import run_pipeline_execution
|
|
|
|
run_pipeline_execution.delay(execution.id, str(execution.tenant_id) if execution.tenant_id else None)
|