1307 lines
48 KiB
Python
1307 lines
48 KiB
Python
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 (
|
|
FileIndex,
|
|
Project,
|
|
ProjectAsset,
|
|
ProjectContributor,
|
|
ProjectDraft,
|
|
Tag,
|
|
TagCategory,
|
|
TagList,
|
|
TempUpload,
|
|
Version,
|
|
VersionFile,
|
|
slugify_tag,
|
|
)
|
|
from .storage import delete_file_index, move_file_index, refresh_file_index, store_temp_file
|
|
from .zips import (
|
|
inject_animationframework,
|
|
parse_mods_manifest,
|
|
read_animation_manifest,
|
|
read_logical_manifest,
|
|
read_models_manifest,
|
|
read_pack_meta,
|
|
read_pack_mcmeta,
|
|
read_plugins_manifest,
|
|
read_skins_manifest,
|
|
read_mod_version_manifest,
|
|
)
|
|
|
|
|
|
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/<uuid>/ → full file (200)
|
|
Range: bytes=0-99 → partial content (206)
|
|
Range: bytes=<start>- → tail from <start>
|
|
Range: bytes=-<suffix> → last <suffix> 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 == '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 = []
|
|
for tag in tags:
|
|
active = tag.name in tag_names
|
|
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})
|
|
|
|
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 = [vf for vf in latest.files.all() if vf.is_markdown]
|
|
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)
|
|
|
|
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,
|
|
'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,
|
|
})
|
|
|
|
|
|
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()
|
|
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_<pk>/ 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 _apply_content_tags(project, tag_names, actor):
|
|
"""Auto-create a content:<tag> for every distinct animation content tag."""
|
|
content_cat = TagCategory.objects.filter(slug='content').first()
|
|
if content_cat is None:
|
|
return
|
|
for name in tag_names:
|
|
name = slugify_tag(name)
|
|
if not name:
|
|
continue
|
|
tag, _ = Tag.objects.get_or_create(
|
|
name=name, category=content_cat, defaults={'created_by': actor},
|
|
)
|
|
TagList.objects.get_or_create(project=project, tag=tag, defaults={'added_by': actor})
|
|
|
|
|
|
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()
|
|
|
|
|
|
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)
|
|
|
|
if animation_manifest is not None:
|
|
_apply_content_tags(
|
|
project, animation_manifest.get('content_tags') or [], actor,
|
|
)
|
|
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)
|
|
messages.success(request, 'Project updated.')
|
|
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.')
|
|
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.')
|
|
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(),
|
|
})
|
|
|
|
|
|
# ---------- 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)
|