"""Shared file-persistence helpers for UGC uploads. Stored under MEDIA_ROOT/project_/... mirroring the avatar layout (avatars/user_/picture.jpeg). Draft uploads live under MEDIA_ROOT/uploads/user_/ until a project adopts them. All files are indexed in FileIndex and served only through the gated file_request endpoint. """ import hashlib import os import uuid from django.core.files.base import ContentFile from django.core.files.storage import default_storage from .models import FileIndex def _compute_md5_size(uploaded): md5 = hashlib.md5() size = 0 uploaded.seek(0) while True: chunk = uploaded.read(1024 * 1024) if not chunk: break md5.update(chunk) size += len(chunk) uploaded.seek(0) return md5.hexdigest(), size def store_file(owner, kind, uploaded, project_pk, subdir='', original_filename=''): """Persist an uploaded file and index it. Returns the FileIndex row. ``subdir`` is e.g. 'versions' or 'gallery'. """ name = getattr(uploaded, 'name', '') or original_filename or 'file' content_type = getattr(uploaded, 'content_type', '') or 'application/octet-stream' md5, size = _compute_md5_size(uploaded) ext = os.path.splitext(name)[1] or '' if subdir: stored_path = f'project_{project_pk}/{subdir}/{uuid.uuid4().hex}{ext}' else: stored_path = f'project_{project_pk}/{uuid.uuid4().hex}{ext}' actual_path = default_storage.save(stored_path, ContentFile(uploaded.read())) return FileIndex.objects.create( owner=owner, kind=kind, stored_path=actual_path, original_filename=name, content_type=content_type, size=size, md5=md5, ) def store_temp_file(owner, kind, uploaded): """Persist a draft upload under uploads/user_/ and return its FileIndex.""" name = getattr(uploaded, 'name', '') or 'file' content_type = getattr(uploaded, 'content_type', '') or 'application/octet-stream' md5, size = _compute_md5_size(uploaded) ext = os.path.splitext(name)[1] or '' stored_path = f'uploads/user_{owner.pk}/{uuid.uuid4().hex}{ext}' actual_path = default_storage.save(stored_path, ContentFile(uploaded.read())) return FileIndex.objects.create( owner=owner, kind=f'temp_{kind}', stored_path=actual_path, original_filename=name, content_type=content_type, size=size, md5=md5, ) def move_file_index(file_index, new_stored_path, kind=None): """Move an indexed file to a new stored path (chunked copy + delete old), updating the index. Used when a project adopts a temp upload.""" if file_index.stored_path and file_index.stored_path != new_stored_path: with default_storage.open(file_index.stored_path, 'rb') as src: actual_path = default_storage.save(new_stored_path, src) if actual_path != file_index.stored_path and default_storage.exists(file_index.stored_path): default_storage.delete(file_index.stored_path) file_index.stored_path = actual_path if kind: file_index.kind = kind file_index.save(update_fields=['stored_path'] if not kind else ['stored_path', 'kind']) return file_index def delete_file_index(file_index): """Remove an indexed file from disk and DB (safe no-op when None).""" if file_index is None: return if file_index.stored_path and default_storage.exists(file_index.stored_path): default_storage.delete(file_index.stored_path) file_index.delete()