import json import mimetypes import posixpath import re import zipfile from pathlib import Path from django.conf import settings from django.contrib import messages from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator from django.db.models import Count, F, Q, Sum from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse, StreamingHttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.text import slugify from common.markdown import render_markdown from .forms import ContributorForm, ProjectForm, VersionForm from .models import ( Comment, FileIndex, Project, ProjectAsset, ProjectContributor, ProjectDraft, Rating, Tag, TagCategory, TagList, TempUpload, Version, VersionFile, slugify_tag, ) from .storage import delete_file_index, move_file_index, refresh_file_index, store_temp_file from announcements.models import Notification from announcements.notifications import notify_project_change from .zips import ( inject_animationframework, parse_mods_manifest, read_animation_manifest, read_logical_manifest, read_models_manifest, read_mod_version_manifest, read_pack_meta, read_pack_mcmeta, read_plugins_manifest, read_skins_manifest, _mc_from_filename, ) def _open_indexed_file(file_index): """Return an open binary file handle for the indexed file (404-safe).""" stored = file_index.stored_path if not stored or posixpath.isabs(stored) or '..' in stored.split('/'): raise Http404 file_path = (Path(settings.MEDIA_ROOT) / stored).resolve() if not file_path.is_relative_to(Path(settings.MEDIA_ROOT).resolve()): raise Http404 if not file_path.is_file(): raise Http404 return open(file_path, 'rb') def _unsatisfiable(content_length): resp = HttpResponse(status=416) resp['Content-Range'] = f'bytes */{content_length}' return resp def file_request(request, file_id): """Serve an indexed file behind the gate, honouring HTTP byte ranges. GET /api/files// → full file (200) Range: bytes=0-99 → partial content (206) Range: bytes=- → tail from Range: bytes=- → last bytes ?download=1 → Content-Disposition: attachment """ file_index = get_object_or_404(FileIndex, uuid=file_id) fh = _open_indexed_file(file_index) content_type = file_index.content_type or 'application/octet-stream' content_length = file_index.size or 0 disposition = '' if request.GET.get('download') == '1': filename = file_index.original_filename or posixpath.basename(file_index.stored_path) disposition = f'attachment; filename="{filename}"' range_header = request.META.get('HTTP_RANGE', '').strip() if not range_header or not range_header.lower().startswith('bytes='): response = FileResponse(fh, content_type=content_type) response['Accept-Ranges'] = 'bytes' response['Content-Length'] = str(content_length) if disposition: response['Content-Disposition'] = disposition return response try: start, _, end = range_header[6:].partition('-') start = int(start) if start else None end = int(end) if end else None except ValueError: return _unsatisfiable(content_length) if start is None and end is None: return _unsatisfiable(content_length) if start is None: suffix = end if suffix <= 0: return _unsatisfiable(content_length) start = max(content_length - suffix, 0) end = content_length - 1 elif end is None: end = content_length - 1 if start < 0 or start >= content_length or end < start: return _unsatisfiable(content_length) end = min(end, content_length - 1) length = end - start + 1 fh.seek(start) def chunk_generator(): try: remaining = length while remaining > 0: data = fh.read(min(8192, remaining)) if not data: break remaining -= len(data) yield data finally: fh.close() part = StreamingHttpResponse(chunk_generator(), content_type=content_type) part['Content-Range'] = f'bytes {start}-{end}/{content_length}' part['Accept-Ranges'] = 'bytes' part['Content-Length'] = str(length) part.status_code = 206 if disposition: part['Content-Disposition'] = disposition return part def _require_can_edit(request, project): if not project.can_edit(request.user): raise HttpResponseForbidden('You do not have permission to edit this project.') return True def _unique_project_slug(title): base = slugify(title)[:100] or 'project' slug, n = base, 1 while Project.objects.filter(slug=slug).exists(): n += 1 slug = f'{base}-{n}' return slug def _tags_from_text(text): """Parse a raw tag string ('general:furry loader:fabric 1.20.1') into (category_slug, tag_name) tuples. Unprefixed names default to general.""" pairs = [] for part in (text or '').replace(',', ' ').split(): if ':' in part: category_slug, _, name = part.partition(':') else: category_slug, name = 'general', part name = slugify_tag(name) if not name or not category_slug: continue pairs.append((category_slug, name)) return pairs def _managed_creator_names(project): """Slugified usernames of the owner + contributors — the creator tags that sync_creator_tags manages automatically. Creator tags that are NOT backed by an account (e.g. an offline artist) are treated as manual and stay editable/removable.""" names = {slugify_tag(project.owner.username)} names.update( slugify_tag(c.user.username) for c in project.contributors.select_related('user') ) return names def _apply_tags(project, raw_tags, actor): """Replace a project's manual tags (auto-creating new ones). Account-backed creator tags (owner + contributors) are managed by sync_creator_tags and preserved; any other creator tag is editable like any other manual tag.""" creator_cat = TagCategory.objects.filter(slug='creator').first() to_delete = project.tag_links.all() if creator_cat is not None: to_delete = to_delete.exclude( tag__category=creator_cat, tag__name__in=_managed_creator_names(project), ) to_delete.delete() for category_slug, name in _tags_from_text(raw_tags): category = TagCategory.objects.filter(slug=category_slug).first() if category is None: category = TagCategory.objects.filter(slug='general').first() if category is None: continue tag, _ = Tag.objects.get_or_create( name=name, category=category, defaults={'created_by': actor}, ) TagList.objects.get_or_create(project=project, tag=tag, defaults={'added_by': actor}) def _current_tags(project): """Serialize a project's editable tags back to 'category:name' form. Account-backed creator tags (owner + contributors) are auto-managed and hidden; manual creator tags (no matching account) remain visible so they can be removed.""" creator_cat = TagCategory.objects.filter(slug='creator').first() qs = project.tag_links.select_related('tag__category').all() if creator_cat is not None: qs = qs.exclude( tag__category=creator_cat, tag__name__in=_managed_creator_names(project), ) return ' '.join(f'{tl.tag.category.slug}:{tl.tag.name}' for tl in qs) def browse(request): q = request.GET.get('q', '').strip() category = request.GET.get('category', '').strip() sort = request.GET.get('sort', 'recent') tag_names = [slugify_tag(t) for t in request.GET.getlist('tag') if t.strip()] projects = ( Project.objects.select_related('owner', 'thumbnail') .prefetch_related('tag_links__tag__category', 'versions') ) if q: projects = projects.filter( Q(title__icontains=q) | Q(summary__icontains=q) | Q(description__icontains=q) | Q(tag_links__tag__name__icontains=q) ).distinct() if category and category in dict(Project.CATEGORY_CHOICES): projects = projects.filter(category=category) else: category = '' for name in tag_names: projects = projects.filter(tag_links__tag__name=name) if sort == 'downloads': projects = projects.annotate(_downloads=Sum('versions__downloads')).order_by( F('_downloads').desc(nulls_last=True) ) elif sort == 'most_liked': projects = projects.order_by('-rating_score') elif sort == 'most_voted': projects = projects.order_by('-rating_count') elif sort == 'name': projects = projects.order_by('title') else: projects = projects.order_by('-created_at') # Preserve the query params when toggling filters. def _qs(**overrides): params = request.GET.copy() for key, value in overrides.items(): if value is None: params.pop(key, None) else: params.setlist(key, value if isinstance(value, list) else [value]) return params.urlencode() category_menu = [('', 'All', f'?{_qs(category=None)}')] for value, label in Project.CATEGORY_CHOICES: category_menu.append((value, label, f'?{_qs(category=value)}')) tag_groups = [] for cat in TagCategory.objects.all().order_by('slug'): tags = ( Tag.objects.filter(category=cat) .annotate(count=Count('project_links')) .filter(count__gt=0) .order_by('-count')[:25] ) if not tags: continue items = [] has_active = False for tag in tags: active = tag.name in tag_names has_active = has_active or active if active: remaining = [t for t in tag_names if t != tag.name] url = f'?{_qs(tag=remaining)}' else: url = f'?{_qs(tag=tag_names + [tag.name])}' items.append({ 'name': tag.name, 'color': cat.color, 'count': tag.count, 'active': active, 'url': url, }) tag_groups.append({'category': cat, 'items': items, 'has_active': has_active}) paginator = Paginator(projects, 12) page_obj = paginator.get_page(request.GET.get('page')) context = { 'projects': page_obj, 'q': q, 'category': category, 'sort': sort, 'active_tags': tag_names, 'category_menu': category_menu, 'tag_groups': tag_groups, 'query_string': _qs(), } return render(request, 'library/browse.html', context) def project_detail(request, slug): project = get_object_or_404( Project.objects.select_related('owner', 'thumbnail'), slug=slug, ) versions = project.versions.prefetch_related('files__file').all() assets = project.assets.select_related('file').all() tags = project.tag_links.select_related('tag__category').all() can_edit = project.can_edit(request.user) guide_docs = [] animation_manifest = None models_manifest = None logical_manifest = None mods_manifest = None plugins_manifest = None skins_manifest = None mod_manifest = None anim_stats = None latest = versions.first() if project.category == 'guide' and latest is not None: guide_docs = [] seen = set() for vf in latest.files.all(): if vf.is_markdown: guide_docs.append({ 'filename': vf.filename, 'url': reverse('library:guide_doc', args=[project.slug, latest.pk, vf.file.uuid]), }) seen.add((vf.filename or '').lower()) release = latest.files.filter(kind='release').first() if release is not None and (release.filename or '').lower().endswith('.zip'): try: with zipfile.ZipFile(_stored_path(release.file)) as zf: members = [ m for m in zf.namelist() if not m.endswith('/') and m.lower().endswith('.md') ] except (zipfile.BadZipFile, OSError, FileNotFoundError): members = [] members.sort(key=lambda m: ( not _guide_norm_path(m).rsplit('/', 1)[-1].startswith(('readme', 'index')), m, )) for m in members: bname = _guide_norm_path(m).rsplit('/', 1)[-1] if bname in seen: continue seen.add(bname) guide_docs.append({ 'filename': bname, 'url': reverse('library:guide_zip_doc', args=[project.slug, latest.pk, m]), }) if latest is not None: animation_manifest = latest.animation_manifest or None models_manifest = latest.models_manifest or None logical_manifest = latest.logical_manifest or None mods_manifest = latest.mods_manifest or None plugins_manifest = latest.plugins_manifest or None skins_manifest = latest.skins_manifest or None mod_manifest = latest.mod_manifest or None if animation_manifest: anims = animation_manifest.get('animations') or [] entity_count = sum(1 for a in anims if a.get('type') == 'Entity x Player') solo_count = sum(1 for a in anims if a.get('type') == 'Solo') anim_stats = { 'total': len(anims), 'entity': entity_count, 'player': len(anims) - entity_count - solo_count, 'solo': solo_count, 'flagged': sum(1 for a in anims if a.get('problem_tags')), 'tags': sorted({t for a in anims for t in (a.get('content_tags') or [])}), } mod_loaders = [] mod_count = 0 if mod_manifest: mods = mod_manifest.get('mods') or [] mod_count = len(mods) for m in mods: loader = m.get('modloader') if loader and loader not in mod_loaders: mod_loaders.append(loader) # Ratings + forum. rating_agg = project.ratings.aggregate( positive=Count('pk', filter=Q(value=True)), total=Count('pk'), ) rating_positive = rating_agg['positive'] or 0 rating_total = rating_agg['total'] or 0 rating_negative = rating_total - rating_positive rating_percent = round(rating_positive / rating_total * 100) if rating_total else 0 user_rating = None if request.user.is_authenticated: user_rating = project.ratings.filter(user=request.user).values_list('value', flat=True).first() comments = project.comments.select_related('user').order_by('-created_at')[:200] can_comment = request.user.is_authenticated # Visiting the UGC page marks the user's personal notifications for it read. if request.user.is_authenticated: Notification.objects.filter( user=request.user, project=project, read=False, ).update(read=True) autoload_plugins_json = '' if plugins_manifest: members = [ p['member'] for p in plugins_manifest.get('plugins', []) if p.get('autoload') and p.get('member') ] autoload_plugins_json = json.dumps(members) context = { 'project': project, 'versions': versions, 'assets': assets, 'tags': tags, 'can_edit': can_edit, 'guide_docs': guide_docs, 'animation_manifest': animation_manifest, 'models_manifest': models_manifest, 'logical_manifest': logical_manifest, 'mods_manifest': mods_manifest, 'plugins_manifest': plugins_manifest, 'skins_manifest': skins_manifest, 'mod_manifest': mod_manifest, 'mod_loaders': mod_loaders, 'mod_count': mod_count, 'rating_stats': { 'positive': rating_positive, 'negative': rating_negative, 'total': rating_total, 'percent': rating_percent, 'negative_percent': 100 - rating_percent, }, 'user_rating': user_rating, 'comments': comments, 'can_comment': can_comment, 'autoload_plugins_json': autoload_plugins_json, 'anim_stats': anim_stats, } return render(request, 'library/project_detail.html', context) def pack_asset(request, slug, version_id, asset_path): """Serve a named member from a stored version zip (gated), used for the Models/Textures + Logical tabs and the model viewer.""" version = get_object_or_404( Version.objects.select_related('project'), pk=version_id, project__slug=slug, ) if not asset_path or asset_path.startswith('/') or '..' in asset_path.split('/'): raise Http404 vf = ( version.files.select_related('file').filter(kind='release').first() or version.files.select_related('file').first() ) if vf is None: raise Http404 with zipfile.ZipFile(_stored_path(vf.file)) as zf: if asset_path not in zf.namelist(): raise Http404 data = zf.read(asset_path) content_type = mimetypes.guess_type(asset_path)[0] or 'application/octet-stream' return HttpResponse(data, content_type=content_type) @login_required def api_packs_latest(request, namespace, pack_id): """Auto-updater endpoint: newest version for a NoN pack id (namespace:pack_id).""" animation_id = f'{namespace}:{pack_id}' version = ( Version.objects.filter(animation_id=animation_id) .select_related('project').prefetch_related('files__file') .order_by('-created_at').first() ) if version is None: return JsonResponse({'detail': 'Not found'}, status=404) release = next( (vf for vf in version.files.all() if vf.kind == 'release'), version.files.first(), ) if release is None: return JsonResponse({'detail': 'No file'}, status=404) download_url = request.build_absolute_uri( reverse('library:file_request', args=[release.file.uuid]) + '?download=1' ) return JsonResponse({ 'namespace': namespace, 'pack_id': pack_id, 'name': version.project.title, 'version': version.version_name, 'pack_format': version.pack_format, 'created_at': version.created_at.isoformat(), 'download_url': download_url, }) _GUIDE_LINK_RE = re.compile(r'(!?\[[^\]]*\]\()([^\s)]+)([^)]*)(\))') _GUIDE_REF_RE = re.compile(r'^(\[[^\]]+\]:\s*)(\S+)\s*$', re.MULTILINE) _GUIDE_LINK_SKIP = ('http://', 'https://', '//', 'data:', 'mailto:', '#', 'tel:', 'javascript:') def _guide_norm_path(path): """Normalize a link target / stored path for matching (lowercase, no ./ or /).""" p = (path or '').strip().replace('\\', '/') while p.startswith('./'): p = p[2:] p = p.lstrip('/') return p.lower() def _guide_asset_maps(version, base_dir=None, source='zip'): """Build normalized path/basename -> served URL maps for a guide version. ``full_map`` keys are normalized full paths, ``base_map`` keys are normalized basenames. First match wins; priority is: members inside the current doc's own zip directory -> standalone uploaded files -> other zip members. Markdown members map to the rendered-doc endpoints, everything else to the raw file/asset endpoints.""" slug = version.project.slug full = {} base = {} def _add(path, url): norm = _guide_norm_path(path) full.setdefault(norm, url) bname = norm.rsplit('/', 1)[-1] if bname and bname not in base: base[bname] = url release = version.files.filter(kind='release').first() zip_members = [] if release is not None and (release.filename or '').lower().endswith('.zip'): try: with zipfile.ZipFile(_stored_path(release.file)) as zf: zip_members = [m for m in zf.namelist() if not m.endswith('/')] except (zipfile.BadZipFile, OSError, FileNotFoundError): zip_members = [] def _zip_url(member): if member.lower().endswith('.md'): return reverse('library:guide_zip_doc', args=[slug, version.pk, member]) return reverse('library:pack_asset', args=[slug, version.pk, member]) def _under_base(member): if not base_dir: return False norm = _guide_norm_path(member) return norm == base_dir or norm.startswith(base_dir + '/') if source == 'zip': for m in zip_members: if _under_base(m): _add(m, _zip_url(m)) for vf in version.files.all(): name = (vf.filename or '').replace('\\', '/') if not name or (vf.kind == 'release' and name.lower().endswith('.zip')): continue if vf.is_markdown: url = reverse('library:guide_doc', args=[slug, version.pk, vf.file.uuid]) else: url = reverse('library:file_request', args=[vf.file.uuid]) _add(name, url) for m in zip_members: if not (source == 'zip' and _under_base(m)): _add(m, _zip_url(m)) return full, base def _resolve_guide_src(src, full_map, base_map, base_dir=None): """Rewrite one markdown target to its served URL, or None when unknown.""" if not src or src.startswith(_GUIDE_LINK_SKIP): return None path = src.split('#')[0].split('?')[0] if not path: return None norm = _guide_norm_path(path) if base_dir: cand = _guide_norm_path(f'{base_dir}/{path}') if cand in full_map: return full_map[cand] if norm in full_map: return full_map[norm] bname = norm.rsplit('/', 1)[-1] if bname in base_map: return base_map[bname] return None def _rewrite_guide_links(md, version, base_dir=None, source='zip'): """Rewrite markdown link/image/reference targets to served URLs whenever a file with the same name is present in the version's uploads (individual files or the release zip). Guide category only.""" full_map, base_map = _guide_asset_maps(version, base_dir=base_dir, source=source) def _sub_inline(m): new = _resolve_guide_src(m.group(2), full_map, base_map, base_dir) return f'{m.group(1)}{new or m.group(2)}{m.group(3)}{m.group(4)}' def _sub_ref(m): new = _resolve_guide_src(m.group(2), full_map, base_map, base_dir) return f'{m.group(1)}{new or m.group(2)}' md = _GUIDE_LINK_RE.sub(_sub_inline, md) md = _GUIDE_REF_RE.sub(_sub_ref, md) return md def guide_doc(request, slug, version_id, file_uuid): """Render one markdown guide document server-side (used by the switcher).""" version = get_object_or_404( Version.objects.select_related('project'), pk=version_id, project__slug=slug, ) vf = get_object_or_404( version.files.select_related('file'), file__uuid=file_uuid, ) if not vf.is_markdown: raise Http404 fh = _open_indexed_file(vf.file) try: text = fh.read().decode('utf-8', 'replace') finally: fh.close() if version.project.category == 'guide': text = _rewrite_guide_links(text, version, source='file') return HttpResponse(render_markdown(text), content_type='text/html; charset=utf-8') def guide_zip_doc(request, slug, version_id, asset_path): """Render one markdown document stored inside the version's release zip.""" version = get_object_or_404( Version.objects.select_related('project'), pk=version_id, project__slug=slug, ) if version.project.category != 'guide': raise Http404 if not asset_path or asset_path.startswith('/') or '..' in asset_path.split('/'): raise Http404 if not asset_path.lower().endswith('.md'): raise Http404 vf = ( version.files.select_related('file').filter(kind='release').first() or version.files.select_related('file').first() ) if vf is None or not (vf.filename or '').lower().endswith('.zip'): raise Http404 with zipfile.ZipFile(_stored_path(vf.file)) as zf: if asset_path not in zf.namelist(): raise Http404 data = zf.read(asset_path) text = data.decode('utf-8', 'replace') text = _rewrite_guide_links( text, version, base_dir=posixpath.dirname(asset_path), source='zip', ) return HttpResponse(render_markdown(text), content_type='text/html; charset=utf-8') DRAFT_KEYS = ( 'title', 'summary', 'category', 'description', 'tags', 'version_name', 'changelog', ) def _serialize_temp(upload): return { 'uuid': str(upload.uuid), 'kind': upload.kind, 'filename': upload.file.original_filename, 'content_type': upload.file.content_type, 'url': reverse('library:file_request', args=[upload.file.uuid]), } def _pending_uploads(user): return list( TempUpload.objects.filter(user=user, status='pending') .select_related('file').order_by('created_at') ) def _pending_kind(user, kind): return list( TempUpload.objects.filter(user=user, status='pending', kind=kind) .select_related('file').order_by('created_at') ) def _latest_pending(user, kind): return ( TempUpload.objects.filter(user=user, status='pending', kind=kind) .select_related('file').order_by('created_at').last() ) def _adopt_temp(upload, project_pk, subdir, kind): """Move a temp upload under project_/ and return the FileIndex.""" base = posixpath.basename(upload.file.stored_path) new_path = f'project_{project_pk}/{subdir}/{base}' if subdir else f'project_{project_pk}/{base}' return move_file_index(upload.file, new_path, kind=kind) def _classify_version_file(file_index, category): """Pick a VersionFile.kind from the uploaded file's extension.""" name = (file_index.original_filename or '').lower() if name.endswith('.md'): return 'markdown' if name.endswith('.png'): return 'png' return 'release' def _stored_path(file_index): """Resolve an indexed file's stored_path to an absolute filesystem path.""" return Path(settings.MEDIA_ROOT) / file_index.stored_path ANIMATION_API_WARNING = ( 'Warning: your pack is incompatible with the update-check API endpoint and ' 'may not work with applications that check for updates. Add an ' "'animationframework' block to pack.mcmeta, e.g.: " '{"pack":{"pack_format":64},"animationframework":{"id":"yourname:yourpack",' '"name":"Your Pack","author":"You","version":"1.0.0","description":"..."}}' ) def _clean_mc_version(value): """Extract the first X.Y from an MC version/range ('>=26.2 <26.3' -> '26.2').""" m = re.search(r'(\d+\.\d+)', str(value or '')) return m.group(1) if m else '' def _vanilla_entities(): """Set of real Minecraft entity names from the bundled vanilla index. Excludes renderer aliases (slime_size_*, player_slim) that are model variants rather than Minecraft entities. """ aliases = {'slime_size_0', 'slime_size_1', 'slime_size_3', 'player_slim'} try: with open(settings.BASE_DIR / 'static' / 'vanilla' / 'entity_index.json') as f: return set(json.load(f).keys()) - aliases except (OSError, ValueError): return set() def _species_entity(entity): """Map model-variant names to their real Minecraft entity.""" return { 'player_slim': 'player', 'slime_size_0': 'slime', 'slime_size_1': 'slime', 'slime_size_3': 'slime', }.get(entity, entity) def _auto_tag_specs(project, version): """Deduped list of (category_slug, tag_name) auto-tags for a version.""" specs = [] def add(cat, name): name = slugify_tag(name) if name and (cat, name) not in specs: specs.append((cat, name)) if project.category == 'mod': for m in (version.mod_manifest or {}).get('mods', []): if m.get('modloader'): add('loader', m['modloader']) mc = _clean_mc_version(m.get('minecraft')) if not mc: mc = _mc_from_filename(m.get('file_name') or '') if mc: add('version', mc) elif project.category == 'modpack': for loader in (version.mods_manifest or {}).get('modloaders', []): add('loader', loader) mc = (version.mods_manifest or {}).get('minecraft') if mc: add('version', mc) elif project.category == 'model': vanilla = _vanilla_entities() for m in (version.models_manifest or {}).get('models', []): ext = (m.get('ext') or '').lstrip('.') if ext == 'geo.json': ext = 'geo' if ext: add('format', ext) entity = _species_entity(m.get('entity') or '') if entity and entity in vanilla: add('species', entity) elif project.category == 'skin': add('general', 'skin') elif project.category == 'guide': add('general', 'guide') elif project.category == 'non_pack': # NoN packs may carry custom/modded species — no vanilla whitelist. # Only skip placeholder model names (pregnancy-egg stubs, etc.). placeholders = {'default', 'minecraft_spider'} for tag in (version.animation_manifest or {}).get('content_tags', []): add('content', tag) for m in (version.models_manifest or {}).get('models', []): entity = _species_entity(m.get('entity') or '') if entity and entity not in placeholders: add('species', entity) return specs def _sync_auto_tags(project, actor=None, version=None): """Apply auto tags (loader/version/format/species/general/content) from the given version's manifests. Marks auto links, cleans stale ones, and never touches manual links (arbitrary tags like creator:LiZo are preserved).""" actor = actor or project.owner version = version or project.versions.first() if version is None: return TagCategory.objects.get_or_create(slug='format', defaults={'name': 'Format', 'color': '#0e7490'}) specs = _auto_tag_specs(project, version) spec_set = set(specs) cats = {} for cat_slug, name in specs: cat = cats.get(cat_slug) or TagCategory.objects.filter(slug=cat_slug).first() if cat is None: continue cats[cat_slug] = cat tag, _ = Tag.objects.get_or_create( name=name, category=cat, defaults={'created_by': actor}, ) link, created = TagList.objects.get_or_create( project=project, tag=tag, defaults={'added_by': actor, 'auto': True}, ) if not created and not link.auto: link.auto = True link.save(update_fields=['auto']) for link in project.tag_links.select_related('tag__category').filter(auto=True).all(): if (link.tag.category.slug, link.tag.name) not in spec_set: link.delete() def _established_animation_id(project): """The animationframework.id of the project's newest version (its identity).""" latest = project.versions.first() return latest.animation_id if latest is not None else '' def _valid_id_component(value): return bool(re.match(r'^[a-zA-Z0-9_.-]+$', value or '')) def _animation_fields(project, version_name, animation_id): return { 'id': animation_id, 'name': project.title, 'author': project.owner.username, 'version': version_name, 'description': project.summary or project.title, } def _inject_animation_metadata(request, project, version_name, temp_uploads): """For NoN-pack zips missing an animationframework.id, rewrite the temp zip to add the block. Returns (errors, injected_count).""" if project.category != 'non_pack': return [], 0 established = _established_animation_id(project) default_ns = slugify(project.owner.username) or 'site' default_pid = f'project_{project.pk}' ns = (request.POST.get('af_namespace') or default_ns).strip() pid = (request.POST.get('af_pack_id') or default_pid).strip() create_pack = { 'pack_format': 64, 'supported_formats': [64, 81], 'min_format': 64, 'max_format': 81, 'description': project.title, } errors = [] injected = 0 for temp in temp_uploads: if not (temp.file.original_filename or '').lower().endswith('.zip'): continue path = _stored_path(temp.file) meta = read_pack_meta(path) current = meta.get('animation_id') if meta else None if current: if established and current != established and not request.POST.get('confirm_replace_identity'): errors.append( 'The uploaded pack has a different animationframework.id than this project. ' 'Check "replace pack identity" to accept it.' ) continue desired = established or f'{ns}:{pid}' if not _valid_id_component(ns) or not _valid_id_component(pid): errors.append('Animation ID must only use letters, numbers, underscores, dots or dashes.') break if inject_animationframework( path, _animation_fields(project, version_name, desired), create_pack, ): refresh_file_index(temp.file) injected += 1 return errors, injected def _animation_meta_for_upload(request, project): """Render hints for the version-upload page's Animation ID section.""" if project.category != 'non_pack': return {'show': False} established = _established_animation_id(project) default_ns = slugify(project.owner.username) or 'site' default_pid = f'project_{project.pk}' show_fields = False show_confirm = False for temp in _pending_kind(request.user, 'version'): if not (temp.file.original_filename or '').lower().endswith('.zip'): continue meta = read_pack_meta(_stored_path(temp.file)) current = meta.get('animation_id') if meta else None if not current and not established: show_fields = True if current and established and current != established: show_confirm = True if established: ns, _, pid = established.partition(':') default_ns = ns or default_ns default_pid = pid or default_pid return { 'show': show_fields or show_confirm, 'show_fields': show_fields, 'show_confirm': show_confirm, 'established': established, 'namespace': default_ns, 'pack_id': default_pid, } def _reparse_version(version): """Re-derive a version's metadata (id + manifests) from its release zip.""" release = version.files.filter(kind='release').first() if release is None: return path = _stored_path(release.file) meta = read_pack_meta(path) if meta and meta.get('animation_id'): version.animation_id = meta['animation_id'] else: version.animation_id = '' version.animation_manifest = read_animation_manifest(path) or {} version.models_manifest = read_models_manifest(path, version.project.category) or {} version.logical_manifest = read_logical_manifest(path) or {} version.plugins_manifest = read_plugins_manifest(path) or {} if version.project.category == 'model' else {} version.skins_manifest = read_skins_manifest(path) or {} if version.project.category == 'skin' else {} if version.project.category == 'mod': entries = [ (vf.filename, _stored_path(vf.file)) for vf in version.files.select_related('file').all() if vf.filename.lower().endswith(('.jar', '.zip')) ] version.mod_manifest = read_mod_version_manifest(entries) else: version.mod_manifest = {} manifest = parse_mods_manifest(path, release.file.original_filename) version.mods_manifest = manifest if manifest.get('files') else {} fmt, desc = read_pack_mcmeta(path) if fmt is not None: version.pack_format = fmt version.pack_description = desc version.save() _sync_auto_tags(version.project, version.project.owner, version.project.versions.first()) def _finalize_version(project, version_name, changelog, temp_uploads, actor): """Adopt pending 'version' temp uploads into a new Version as VersionFiles, capturing category/content-specific metadata (pack.mcmeta, mods, animation, models, logical manifests). Returns (version, missing_animation_id).""" version = Version.objects.create( project=project, version_name=version_name, changelog=changelog, ) pack_format = None pack_description = '' mods_manifest = {} plugins_manifest = None skins_manifest = None mod_file_entries = [] animation_id = '' animation_manifest = None models_manifest = None logical_manifest = None missing_animation_id = False for temp in temp_uploads: index = _adopt_temp(temp, project.pk, 'versions', 'version') VersionFile.objects.create( version=version, file=index, kind=_classify_version_file(index, project.category), ) temp.status = 'used' temp.save(update_fields=['status']) if project.category == 'mod': mod_name = (index.original_filename or '').lower() if mod_name.endswith(('.jar', '.zip')): mod_file_entries.append((index.original_filename, _stored_path(index))) is_archive = (index.original_filename or '').lower().endswith(('.zip', '.mrpack', '.jar')) if not is_archive: continue path = _stored_path(index) if project.category == 'non_pack' and pack_format is None: fmt, desc = read_pack_mcmeta(path) if fmt is not None: pack_format = fmt pack_description = desc if not animation_id: meta = read_pack_meta(path) if meta and meta.get('animation_id'): animation_id = meta['animation_id'] if animation_manifest is None: animation_manifest = read_animation_manifest(path) if models_manifest is None: models_manifest = read_models_manifest(path, project.category) if logical_manifest is None: logical_manifest = read_logical_manifest(path) if not mods_manifest: manifest = parse_mods_manifest(path, index.original_filename) if manifest.get('files'): mods_manifest = manifest if plugins_manifest is None and project.category == 'model': plugins_manifest = read_plugins_manifest(path) if skins_manifest is None and project.category == 'skin': skins_manifest = read_skins_manifest(path) update_fields = [] if pack_format is not None: version.pack_format = pack_format version.pack_description = pack_description update_fields += ['pack_format', 'pack_description'] if mods_manifest: version.mods_manifest = mods_manifest update_fields.append('mods_manifest') if plugins_manifest is not None: version.plugins_manifest = plugins_manifest update_fields.append('plugins_manifest') if skins_manifest is not None: version.skins_manifest = skins_manifest update_fields.append('skins_manifest') if project.category == 'mod' and mod_file_entries: version.mod_manifest = read_mod_version_manifest(mod_file_entries) update_fields.append('mod_manifest') if animation_id: version.animation_id = animation_id update_fields.append('animation_id') if animation_manifest is not None: version.animation_manifest = animation_manifest update_fields.append('animation_manifest') if not animation_id: missing_animation_id = True if models_manifest is not None: version.models_manifest = models_manifest update_fields.append('models_manifest') if logical_manifest is not None: version.logical_manifest = logical_manifest update_fields.append('logical_manifest') if update_fields: version.save(update_fields=update_fields) _sync_auto_tags(project, actor, version) return version, missing_animation_id def _form_values(*forms): out = {} for form in forms: for name in form.fields: out[name] = form[name].value() or '' return out @login_required def project_create(request): project_form = ProjectForm() version_form = VersionForm() if request.method == 'POST': project_form = ProjectForm(request.POST) version_form = VersionForm(request.POST) if project_form.is_valid() and version_form.is_valid(): data = project_form.cleaned_data version_files = _pending_kind(request.user, 'version') if not version_files: version_form.add_error('version_name', 'Upload at least one version file before creating the project.') else: project = Project.objects.create( slug=_unique_project_slug(data['title']), title=data['title'], summary=data['summary'], description=data['description'], category=data['category'], owner=request.user, ) thumb = _latest_pending(request.user, 'thumbnail') if thumb is not None: project.thumbnail = _adopt_temp(thumb, project.pk, '', 'thumbnail') thumb.status = 'used' thumb.save(update_fields=['status']) project.save(update_fields=['thumbnail']) _inject_animation_metadata( request, project, version_form.cleaned_data['version_name'], version_files, ) _, missing_animation_id = _finalize_version( project, version_form.cleaned_data['version_name'], version_form.cleaned_data['changelog'], version_files, request.user, ) if missing_animation_id and project.category == 'non_pack': messages.warning(request, ANIMATION_API_WARNING) for temp in TempUpload.objects.filter( user=request.user, status='pending', kind='media', ).select_related('file').order_by('created_at'): index = _adopt_temp(temp, project.pk, 'gallery', 'asset') ProjectAsset.objects.create( project=project, file=index, caption=request.POST.get(f'caption_{temp.uuid}', '')[:128], uploaded_by=request.user, ) temp.status = 'used' temp.save(update_fields=['status']) _apply_tags(project, data['tags'], request.user) project.sync_creator_tags(actor=request.user) ProjectDraft.objects.filter(user=request.user).delete() messages.success(request, 'Project created.') return redirect('library:project_detail', slug=project.slug) else: draft = ProjectDraft.objects.filter(user=request.user).first() if draft: initial = {k: draft.data.get(k, '') for k in DRAFT_KEYS} project_form = ProjectForm(initial=initial) version_form = VersionForm( initial={ k: initial[k] for k in ('version_name', 'changelog') if k in initial }, ) return render(request, 'library/project_create.html', { 'project_form': project_form, 'version_form': version_form, 'draft_uploads': [_serialize_temp(u) for u in _pending_uploads(request.user)], 'draft_data': _form_values(project_form, version_form), 'tag_categories': list(TagCategory.objects.order_by('slug').values('slug', 'color')), }) @login_required def project_edit(request, slug): project = get_object_or_404(Project, slug=slug) if not project.can_edit(request.user): return HttpResponseForbidden('You do not have permission to edit this project.') if request.method == 'POST': form = ProjectForm(request.POST) if form.is_valid(): data = form.cleaned_data project.title = data['title'] project.summary = data['summary'] project.description = data['description'] project.category = data['category'] thumb = _latest_pending(request.user, 'thumbnail') if thumb is not None: delete_file_index(project.thumbnail) project.thumbnail = _adopt_temp(thumb, project.pk, '', 'thumbnail') thumb.status = 'used' thumb.save(update_fields=['status']) project.save() _apply_tags(project, data['tags'], request.user) project.sync_creator_tags(actor=request.user) _sync_auto_tags(project, request.user) messages.success(request, 'Project updated.') notify_project_change( project, request.user, 'edit', f'{request.user.username} edited {project.title}', ) return redirect('library:project_detail', slug=project.slug) else: form = ProjectForm(initial={ 'title': project.title, 'summary': project.summary, 'description': project.description, 'category': project.category, 'tags': _current_tags(project), }) thumb = _latest_pending(request.user, 'thumbnail') if thumb is not None: pending_uploads = [_serialize_temp(thumb)] elif project.thumbnail_id: pending_uploads = [{ 'uuid': str(project.thumbnail.uuid), 'kind': 'thumbnail', 'filename': project.thumbnail.original_filename, 'content_type': project.thumbnail.content_type, 'url': project.thumbnail_url, 'removable': False, }] else: pending_uploads = [] established = _established_animation_id(project) ns, _, pid = established.partition(':') anim_edit = { 'current': established, 'namespace': ns or (slugify(project.owner.username) or 'site'), 'pack_id': pid or f'project_{project.pk}', } return render(request, 'library/project_edit.html', { 'project': project, 'project_form': form, 'pending_uploads': pending_uploads, 'tag_categories': list(TagCategory.objects.order_by('slug').values('slug', 'color')), 'anim_edit': anim_edit, }) @login_required def project_delete(request, slug): project = get_object_or_404(Project, slug=slug) if request.user != project.owner: return HttpResponseForbidden('Only the owner can delete this project.') if request.method == 'POST': indices = [] if project.thumbnail_id: indices.append(project.thumbnail) for version in project.versions.prefetch_related('files__file').all(): indices += [vf.file for vf in version.files.all() if vf.file_id] indices += [a.file for a in project.assets.all() if a.file_id] project.delete() for index in indices: delete_file_index(index) messages.success(request, 'Project deleted.') return redirect('library:browse') return render(request, 'library/project_delete.html', {'project': project}) @login_required def version_upload(request, slug): project = get_object_or_404(Project, slug=slug) if not project.can_edit(request.user): return HttpResponseForbidden('You do not have permission to upload versions.') form = VersionForm() if request.method == 'POST': form = VersionForm(request.POST) if form.is_valid(): version_files = _pending_kind(request.user, 'version') if not version_files: form.add_error('version_name', 'Upload at least one version file before uploading.') else: errors, _injected = _inject_animation_metadata( request, project, form.cleaned_data['version_name'], version_files, ) for error in errors: form.add_error(None, error) if not errors: _, missing_animation_id = _finalize_version( project, form.cleaned_data['version_name'], form.cleaned_data['changelog'], version_files, request.user, ) if missing_animation_id and project.category == 'non_pack': messages.warning(request, ANIMATION_API_WARNING) messages.success(request, f'Version {form.cleaned_data["version_name"]} uploaded.') notify_project_change( project, request.user, 'version', f'{request.user.username} uploaded version {form.cleaned_data["version_name"]} on {project.title}', ) return redirect('library:project_detail', slug=project.slug) version_files = _pending_kind(request.user, 'version') return render(request, 'library/version_upload.html', { 'project': project, 'version_form': form, 'pending_uploads': [_serialize_temp(u) for u in version_files], 'anim_meta': _animation_meta_for_upload(request, project), }) def _bump_and_redirect(request, version, file_index): Version.objects.filter(pk=version.pk).update(downloads=F('downloads') + 1) return redirect( reverse('library:file_request', args=[file_index.uuid]) + '?download=1' ) @login_required def regenerate_metadata(request, slug): """Rewrite the latest version's release zip so pack.mcmeta carries the chosen animationframework.id, then re-parse the version's metadata.""" project = get_object_or_404(Project, slug=slug) if not project.can_edit(request.user): return HttpResponseForbidden('You do not have permission to edit this project.') if request.method != 'POST': return HttpResponse(status=405) latest = project.versions.first() if latest is None: messages.error(request, 'No versions to regenerate.') return redirect('library:project_edit', slug=slug) release = latest.files.filter(kind='release').first() if release is None: messages.error(request, 'No release file to regenerate.') return redirect('library:project_edit', slug=slug) ns = request.POST.get('af_namespace', '').strip() pid = request.POST.get('af_pack_id', '').strip() if not _valid_id_component(ns) or not _valid_id_component(pid): messages.error(request, 'Animation ID must only use letters, numbers, underscores, dots or dashes.') return redirect('library:project_edit', slug=slug) animation_id = f'{ns}:{pid}' create_pack = { 'pack_format': 64, 'supported_formats': [64, 81], 'min_format': 64, 'max_format': 81, 'description': project.title, } path = _stored_path(release.file) if not inject_animationframework( path, _animation_fields(project, latest.version_name, animation_id), create_pack, ): messages.error(request, 'Could not regenerate pack metadata.') return redirect('library:project_edit', slug=slug) refresh_file_index(release.file) _reparse_version(latest) messages.success(request, f'Pack metadata regenerated with id {animation_id}.') return redirect('library:project_edit', slug=slug) def version_download(request, slug, version_id): """Download the first release file of a version (kept for API/mods).""" version = get_object_or_404( Version.objects.select_related('project'), pk=version_id, project__slug=slug, ) first = version.files.select_related('file').first() if first is None: raise Http404 return _bump_and_redirect(request, version, first.file) def version_file_download(request, slug, version_id, file_uuid): """Download a specific file of a version.""" version = get_object_or_404( Version.objects.select_related('project'), pk=version_id, project__slug=slug, ) vf = get_object_or_404( version.files.select_related('file'), file__uuid=file_uuid, ) return _bump_and_redirect(request, version, vf.file) @login_required def asset_upload(request, slug): project = get_object_or_404(Project, slug=slug) if not project.can_edit(request.user): return HttpResponseForbidden('You do not have permission to add media.') if request.method == 'POST': media = TempUpload.objects.filter(user=request.user, status='pending', kind='media') if not media.exists(): messages.error(request, 'No media uploaded yet.') else: count = 0 for temp in media.select_related('file'): index = _adopt_temp(temp, project.pk, 'gallery', 'asset') ProjectAsset.objects.create( project=project, file=index, caption=request.POST.get(f'caption_{temp.uuid}', '')[:128], uploaded_by=request.user, ) temp.status = 'used' temp.save(update_fields=['status']) count += 1 messages.success(request, f'{count} media file{"s" if count != 1 else ""} added to the gallery.') notify_project_change( project, request.user, 'asset', f'{request.user.username} added {count} new media file{"s" if count != 1 else ""} to {project.title}', ) return redirect('library:project_detail', slug=project.slug) return render(request, 'library/asset_upload.html', { 'project': project, 'pending_uploads': [_serialize_temp(u) for u in _pending_uploads(request.user)], }) @login_required def asset_delete(request, slug, asset_id): project = get_object_or_404(Project, slug=slug) asset = get_object_or_404(ProjectAsset, pk=asset_id, project=project) if not project.can_edit(request.user): return HttpResponseForbidden('You do not have permission to delete media.') if request.method != 'POST': return HttpResponse(status=405) delete_file_index(asset.file) asset.delete() accept = request.META.get('HTTP_ACCEPT', '') if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or 'application/json' in accept: return JsonResponse({'success': True}) messages.success(request, 'Media deleted.') return redirect('library:project_detail', slug=project.slug) def asset_thumbnail(request, slug, asset_id): """Cached ffmpeg thumbnail for a gallery video/gif (grid preview only; the modal loads the real file).""" from pathlib import Path as _Path from django.http import FileResponse from .thumbnails import generate_thumbnail asset = get_object_or_404( ProjectAsset.objects.select_related('file', 'project'), pk=asset_id, project__slug=slug, ) if not (asset.is_video or asset.is_gif): raise Http404 stored = generate_thumbnail(asset.file) if stored is None: raise Http404 fh = open(_Path(settings.MEDIA_ROOT) / stored, 'rb') response = FileResponse(fh, content_type='image/jpeg') response['Cache-Control'] = 'public, max-age=86400' return response @login_required def contributors(request, slug): project = get_object_or_404(Project, slug=slug) if request.user != project.owner: return HttpResponseForbidden('Only the owner can manage contributors.') if request.method == 'POST': action = request.POST.get('action') if action == 'add': username = request.POST.get('username', '').strip() user = get_user_model().objects.filter(username__iexact=username).first() if user is None: messages.error(request, f'No account named "{username}".') elif user == project.owner: messages.error(request, 'The owner is already a contributor.') else: _, created = ProjectContributor.objects.get_or_create( project=project, user=user, defaults={'added_by': request.user}, ) project.sync_creator_tags(actor=request.user) if created: messages.success(request, f'{user.username} added as a contributor.') else: messages.info(request, f'{user.username} is already a contributor.') elif action == 'remove': contributor = ProjectContributor.objects.filter( pk=request.POST.get('contributor_id'), project=project, ).first() if contributor is not None: user = contributor.user contributor.delete() project.detach_creator_tag(user) project.sync_creator_tags(actor=request.user) messages.success(request, f'{user.username} removed.') return redirect('library:contributors', slug=project.slug) return render(request, 'library/contributors.html', { 'project': project, 'contributors': project.contributors.select_related('user').all(), 'contributor_form': ContributorForm(), }) # ---------- Ratings & Forum ---------- def refresh_rating_fields(project): """Recompute Project.rating_score (net) + rating_count from its votes.""" agg = project.ratings.aggregate( up=Count('pk', filter=Q(value=True)), total=Count('pk'), ) up = agg['up'] or 0 total = agg['total'] or 0 project.rating_count = total project.rating_score = 2 * up - total project.save(update_fields=['rating_score', 'rating_count']) def _require_rating_user(request, project): """Redirect guests to login; return None when allowed.""" if request.user.is_authenticated: return None return redirect('{}?next={}'.format( reverse('profiles:login'), request.path, )) def rate_project(request, slug): """Set / change / clear the current user's vote on a project.""" project = get_object_or_404(Project, slug=slug) if (login_redirect := _require_rating_user(request, project)) is not None: return login_redirect if request.method != 'POST': return redirect('library:project_detail', slug=slug) value_raw = request.POST.get('value', '').strip() if value_raw in ('1', 'up'): value = True elif value_raw in ('0', 'down'): value = False else: value = None # clear the vote rating, created = Rating.objects.get_or_create( project=project, user=request.user, defaults={'value': value if value is not None else True}, ) if value is None: rating.delete() messages.success(request, 'Your rating was removed.') elif created or rating.value != value: rating.value = value rating.save(update_fields=['value', 'updated_at']) messages.success(request, 'Your rating was updated.') else: messages.info(request, 'Your rating is unchanged.') refresh_rating_fields(project) return redirect(reverse('library:project_detail', args=[slug]) + '#rating') def add_comment(request, slug): project = get_object_or_404(Project, slug=slug) if (login_redirect := _require_rating_user(request, project)) is not None: return login_redirect if request.method == 'POST': body = request.POST.get('body', '').strip() if body: Comment.objects.create(project=project, user=request.user, body=body) notify_project_change( project, request.user, 'comment', f'{request.user.username} commented on {project.title}', ) messages.success(request, 'Comment added.') else: messages.error(request, 'Comment cannot be empty.') return redirect(reverse('library:project_detail', args=[slug]) + '#forum') def edit_comment(request, slug, comment_id): project = get_object_or_404(Project, slug=slug) if (login_redirect := _require_rating_user(request, project)) is not None: return login_redirect comment = get_object_or_404(Comment, pk=comment_id, project=project) if comment.user_id != request.user.pk and not request.user.is_staff: return HttpResponseForbidden('You can only edit your own comments.') if request.method == 'POST': body = request.POST.get('body', '').strip() if body: comment.body = body comment.save(update_fields=['body', 'updated_at']) messages.success(request, 'Comment updated.') else: messages.error(request, 'Comment cannot be empty.') return redirect(reverse('library:project_detail', args=[slug]) + '#forum') def delete_comment(request, slug, comment_id): project = get_object_or_404(Project, slug=slug) if (login_redirect := _require_rating_user(request, project)) is not None: return login_redirect comment = get_object_or_404(Comment, pk=comment_id, project=project) if comment.user_id != request.user.pk and not request.user.is_staff: return HttpResponseForbidden('You can only delete your own comments.') if request.method == 'POST': comment.delete() messages.success(request, 'Comment deleted.') return redirect(reverse('library:project_detail', args=[slug]) + '#forum') # ---------- API (autocomplete) ---------- def api_tags_autocomplete(request): q = request.GET.get('q', '').strip() tags = Tag.objects.select_related('category').order_by('name') if q: tags = tags.filter(Q(name__icontains=q) | Q(category__name__icontains=q)) data = [ {'name': t.name, 'category': t.category.slug, 'category_name': t.category.name, 'color': t.category.color} for t in tags[:20] ] return JsonResponse(data, safe=False) def api_users_autocomplete(request): q = request.GET.get('q', '').strip() users = get_user_model().objects.filter(username__icontains=q).order_by('username')[:20] return JsonResponse([{'username': u.username} for u in users], safe=False) # ---------- API (draft uploads + autosave) ---------- MEDIA_PREFIXES = ('image/', 'video/') @login_required def api_upload_temp(request): """Accept one file (J621-style: the JS uploads each file individually).""" if request.method != 'POST': return JsonResponse({'error': 'Method not allowed'}, status=405) uploaded = request.FILES.get('file') if uploaded is None: return JsonResponse({'error': 'No file'}, status=400) kind = request.POST.get('kind', 'media') if kind not in dict(TempUpload.KIND_CHOICES): kind = 'media' content_type = getattr(uploaded, 'content_type', '') or '' if kind in ('thumbnail', 'media'): if not content_type.startswith(MEDIA_PREFIXES): return JsonResponse({'error': 'Only images, videos and gifs are allowed here.'}, status=400) if kind == 'thumbnail' and not content_type.startswith('image/'): return JsonResponse({'error': 'The thumbnail must be an image.'}, status=400) # Thumbnail is a single slot: discard any earlier pending one. Version and # media accept multiple files (one Version can hold several files). if kind == 'thumbnail': for old in TempUpload.objects.filter(user=request.user, status='pending', kind='thumbnail'): delete_file_index(old.file) old.delete() index = store_temp_file(request.user, kind, uploaded) upload = TempUpload.objects.create(user=request.user, kind=kind, file=index) return JsonResponse(_serialize_temp(upload)) @login_required def api_delete_temp(request, upload_uuid): upload = get_object_or_404(TempUpload, uuid=upload_uuid, user=request.user) if request.method != 'POST': return JsonResponse({'error': 'Method not allowed'}, status=405) if upload.status == 'pending': delete_file_index(upload.file) upload.delete() return JsonResponse({'success': True}) @login_required def api_draft(request): if request.method == 'GET': draft = ProjectDraft.objects.filter(user=request.user).first() return JsonResponse({ 'data': draft.data if draft else {}, 'uploads': [_serialize_temp(u) for u in _pending_uploads(request.user)], }) if request.method == 'POST': try: payload = json.loads(request.body or b'{}') except json.JSONDecodeError: payload = {} data = payload.get('data') or {} clean = {k: str(data.get(k, '')) for k in DRAFT_KEYS if k in data} draft, _ = ProjectDraft.objects.get_or_create(user=request.user) draft.data = clean draft.save() return JsonResponse({ 'saved': True, 'updated_at': draft.updated_at.isoformat(), }) if request.method == 'DELETE': ProjectDraft.objects.filter(user=request.user).delete() return JsonResponse({'success': True}) return JsonResponse({'error': 'Method not allowed'}, status=405)