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)