infrasynth-backend-kit/infrasynth/files/services.py
jcv-dev 5df0be1f5c feat: uniform extensibility across every module
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
2026-09-24 11:08:08 -05:00

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)