phase 1 backup - support for multi-file versioned upload
This commit is contained in:
@@ -21,11 +21,6 @@ class ProjectForm(forms.Form):
|
||||
'placeholder': 'Write the description in Markdown…',
|
||||
}),
|
||||
)
|
||||
caption = forms.CharField(
|
||||
max_length=128,
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': 'Optional caption for the gallery media'}),
|
||||
)
|
||||
tags = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.HiddenInput(),
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 6.0.3 on 2026-08-04 03:26
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def copy_version_files(apps, schema_editor):
|
||||
"""Move each Version's single file into a VersionFile row."""
|
||||
Version = apps.get_model('library', 'Version')
|
||||
VersionFile = apps.get_model('library', 'VersionFile')
|
||||
for version in Version.objects.all():
|
||||
if version.file_id:
|
||||
VersionFile.objects.create(
|
||||
version=version, file_id=version.file_id, kind='release',
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('library', '0004_projectdraft_tempupload'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='VersionFile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('kind', models.CharField(choices=[('release', 'Release'), ('markdown', 'Markdown'), ('png', 'PNG')], default='release', max_length=16)),
|
||||
('note', models.CharField(blank=True, default='', max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='version_file', to='library.fileindex')),
|
||||
('version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='library.version')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['created_at', 'pk'],
|
||||
},
|
||||
),
|
||||
migrations.RunPython(copy_version_files, migrations.RunPython.noop),
|
||||
migrations.RemoveField(
|
||||
model_name='version',
|
||||
name='file',
|
||||
),
|
||||
]
|
||||
@@ -201,12 +201,9 @@ class TagList(models.Model):
|
||||
|
||||
|
||||
class Version(models.Model):
|
||||
"""A tracked-file release. Every upload is stored; latest = newest created_at."""
|
||||
"""A release holding one or more VersionFile entries. Latest = newest."""
|
||||
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='versions')
|
||||
version_name = models.CharField(max_length=64)
|
||||
file = models.ForeignKey(
|
||||
FileIndex, on_delete=models.SET_NULL, null=True, blank=True, related_name='project_version',
|
||||
)
|
||||
changelog = models.TextField(blank=True, default='')
|
||||
downloads = models.PositiveIntegerField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
@@ -218,6 +215,39 @@ class Version(models.Model):
|
||||
return f'{self.project.title} {self.version_name}'
|
||||
|
||||
|
||||
class VersionFile(models.Model):
|
||||
"""One file belonging to a Version release. A version can hold several
|
||||
files (e.g. multiple skins), each with its own download."""
|
||||
KIND_CHOICES = [
|
||||
('release', 'Release'),
|
||||
('markdown', 'Markdown'),
|
||||
('png', 'PNG'),
|
||||
]
|
||||
version = models.ForeignKey(Version, on_delete=models.CASCADE, related_name='files')
|
||||
file = models.ForeignKey(FileIndex, on_delete=models.CASCADE, related_name='version_file')
|
||||
kind = models.CharField(max_length=16, choices=KIND_CHOICES, default='release')
|
||||
note = models.CharField(max_length=255, blank=True, default='')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['created_at', 'pk']
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.version} — {self.file.original_filename}'
|
||||
|
||||
@property
|
||||
def filename(self):
|
||||
return self.file.original_filename
|
||||
|
||||
@property
|
||||
def is_markdown(self):
|
||||
return self.kind == 'markdown'
|
||||
|
||||
@property
|
||||
def is_png(self):
|
||||
return self.kind == 'png'
|
||||
|
||||
|
||||
class ProjectAsset(models.Model):
|
||||
"""Untracked gallery media (image/video/gif) displayed on the project page."""
|
||||
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='assets')
|
||||
|
||||
+97
-13
@@ -20,6 +20,7 @@ from .models import (
|
||||
TagList,
|
||||
TempUpload,
|
||||
Version,
|
||||
VersionFile,
|
||||
)
|
||||
|
||||
|
||||
@@ -113,7 +114,7 @@ class UGCProjectTests(UGCMediaTestCase, UGCGatedTestCase):
|
||||
self.assertEqual(project.versions.count(), 1)
|
||||
self.assertIsNotNone(project.thumbnail)
|
||||
self.assertTrue(project.thumbnail.stored_path.startswith('project_'))
|
||||
self.assertTrue(project.versions.first().file.stored_path.startswith('project_'))
|
||||
self.assertTrue(project.versions.first().files.first().file.stored_path.startswith('project_'))
|
||||
# Temp uploads marked used.
|
||||
self.assertEqual(
|
||||
TempUpload.objects.filter(user=self.alice, status='used').count(), 2
|
||||
@@ -138,23 +139,31 @@ class UGCProjectTests(UGCMediaTestCase, UGCGatedTestCase):
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertFalse(Project.objects.filter(slug='no-file').exists())
|
||||
|
||||
def test_create_adopts_media_and_caption(self):
|
||||
def test_create_adopts_media_and_captions(self):
|
||||
self.gate()
|
||||
self.client.login(username='Alice', password='pw')
|
||||
self._upload_temp('thumbnail', 'thumb.png', make_png_bytes(), 'image/png')
|
||||
self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip')
|
||||
self._upload_temp('media', 'pic1.png', make_png_bytes(), 'image/png')
|
||||
self._upload_temp('media', 'pic2.png', make_png_bytes(), 'image/png')
|
||||
media = TempUpload.objects.filter(kind='media').order_by('created_at')
|
||||
caption_payload = {
|
||||
f'caption_{media[0].uuid}': 'Showcase',
|
||||
f'caption_{media[1].uuid}': 'Second angle',
|
||||
}
|
||||
resp = self.client.post(reverse('library:project_create'), {
|
||||
'title': 'Media Pack',
|
||||
'category': 'skin',
|
||||
'version_name': '1.0.0',
|
||||
'caption': 'Showcase',
|
||||
**caption_payload,
|
||||
})
|
||||
self.assertRedirects(resp, reverse('library:project_detail', args=['media-pack']))
|
||||
project = Project.objects.get(slug='media-pack')
|
||||
self.assertEqual(project.assets.count(), 2)
|
||||
self.assertEqual(project.assets.first().caption, 'Showcase')
|
||||
# Per-file captions stored individually.
|
||||
captions = dict(project.assets.values_list('file__original_filename', 'caption'))
|
||||
self.assertEqual(captions['pic1.png'], 'Showcase')
|
||||
self.assertEqual(captions['pic2.png'], 'Second angle')
|
||||
for asset in project.assets.all():
|
||||
self.assertTrue(asset.file.stored_path.startswith('project_'))
|
||||
|
||||
@@ -207,13 +216,39 @@ class UGCVersionTests(UGCMediaTestCase, UGCGatedTestCase):
|
||||
self.assertEqual(self.project.versions.count(), 1)
|
||||
version = self.project.versions.first()
|
||||
self.assertEqual(version.version_name, '2.0.0')
|
||||
self.assertTrue(version.file_id)
|
||||
self.assertEqual(version.files.count(), 1)
|
||||
# Adopted file moved under project_<pk>/versions/ and temp marked used.
|
||||
self.assertTrue(version.file.stored_path.startswith('project_'))
|
||||
self.assertTrue(version.files.first().file.stored_path.startswith('project_'))
|
||||
self.assertTrue(
|
||||
TempUpload.objects.filter(user=self.alice, kind='version', status='used').exists()
|
||||
)
|
||||
|
||||
def test_upload_version_multiple_files(self):
|
||||
self.gate()
|
||||
self.client.login(username='Alice', password='pw')
|
||||
for name in ('skin1.png', 'skin2.png'):
|
||||
self.client.post(
|
||||
reverse('library:api_upload_temp'),
|
||||
{'kind': 'version', 'file': SimpleUploadedFile(name, make_png_bytes(), content_type='image/png')},
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
|
||||
)
|
||||
resp = self.client.post(
|
||||
reverse('library:version_upload', args=['test-pack']),
|
||||
{'version_name': '3.0.0'},
|
||||
)
|
||||
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
|
||||
version = self.project.versions.first()
|
||||
self.assertEqual(version.version_name, '3.0.0')
|
||||
# One version, several files, PNGs classified as png kind.
|
||||
self.assertEqual(version.files.count(), 2)
|
||||
self.assertEqual(
|
||||
set(version.files.values_list('kind', flat=True)), {'png'}
|
||||
)
|
||||
self.assertEqual(
|
||||
set(version.files.values_list('file__original_filename', flat=True)),
|
||||
{'skin1.png', 'skin2.png'},
|
||||
)
|
||||
|
||||
def test_upload_version_requires_pending_file(self):
|
||||
self.gate()
|
||||
self.client.login(username='Alice', password='pw')
|
||||
@@ -259,7 +294,8 @@ class UGCVersionTests(UGCMediaTestCase, UGCGatedTestCase):
|
||||
SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip'),
|
||||
self.project.pk, subdir='versions',
|
||||
)
|
||||
version = Version.objects.create(project=self.project, version_name='1.0.0', file=index)
|
||||
version = Version.objects.create(project=self.project, version_name='1.0.0')
|
||||
VersionFile.objects.create(version=version, file=index, kind='release')
|
||||
self.gate()
|
||||
resp = self.client.get(reverse('library:version_download', args=['test-pack', version.pk]))
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
@@ -270,6 +306,29 @@ class UGCVersionTests(UGCMediaTestCase, UGCGatedTestCase):
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(b''.join(resp.streaming_content), make_zip_bytes())
|
||||
|
||||
def test_per_file_download(self):
|
||||
from library.storage import store_file
|
||||
|
||||
index = store_file(
|
||||
self.alice, 'version',
|
||||
SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip'),
|
||||
self.project.pk, subdir='versions',
|
||||
)
|
||||
version = Version.objects.create(project=self.project, version_name='1.0.0')
|
||||
vf = VersionFile.objects.create(version=version, file=index, kind='release')
|
||||
self.gate()
|
||||
resp = self.client.get(
|
||||
reverse('library:version_file_download', args=['test-pack', version.pk, index.uuid])
|
||||
)
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
version.refresh_from_db()
|
||||
self.assertEqual(version.downloads, 1)
|
||||
self.assertEqual(resp.url, reverse('library:file_request', args=[index.uuid]) + '?download=1')
|
||||
# The parent version_download route still works (first file).
|
||||
resp = self.client.get(reverse('library:version_download', args=['test-pack', version.pk]))
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
self.assertTrue(vf)
|
||||
|
||||
|
||||
class UGCGalleryTests(UGCMediaTestCase, UGCGatedTestCase):
|
||||
def setUp(self):
|
||||
@@ -282,7 +341,7 @@ class UGCGalleryTests(UGCMediaTestCase, UGCGatedTestCase):
|
||||
slug='test-pack', title='Test Pack', category='skin', owner=self.alice,
|
||||
)
|
||||
|
||||
def test_upload_image_asset(self):
|
||||
def test_upload_image_asset_with_caption(self):
|
||||
self.gate()
|
||||
self.client.login(username='Alice', password='pw')
|
||||
self.client.post(
|
||||
@@ -290,13 +349,38 @@ class UGCGalleryTests(UGCMediaTestCase, UGCGatedTestCase):
|
||||
{'kind': 'media', 'file': SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png')},
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
|
||||
)
|
||||
resp = self.client.post(reverse('library:asset_upload', args=['test-pack']), {'caption': 'Preview'})
|
||||
temp = TempUpload.objects.get(kind='media')
|
||||
resp = self.client.post(
|
||||
reverse('library:asset_upload', args=['test-pack']),
|
||||
{f'caption_{temp.uuid}': 'Preview'},
|
||||
)
|
||||
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
|
||||
asset = ProjectAsset.objects.get()
|
||||
self.assertEqual(asset.caption, 'Preview')
|
||||
self.assertEqual(asset.uploaded_by, self.alice)
|
||||
self.assertFalse(asset.is_video)
|
||||
|
||||
def test_asset_captions_are_per_file(self):
|
||||
self.gate()
|
||||
self.client.login(username='Alice', password='pw')
|
||||
self.client.post(
|
||||
reverse('library:api_upload_temp'),
|
||||
{'kind': 'media', 'file': SimpleUploadedFile('a.png', make_png_bytes(color='red'), content_type='image/png')},
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
|
||||
)
|
||||
self.client.post(
|
||||
reverse('library:api_upload_temp'),
|
||||
{'kind': 'media', 'file': SimpleUploadedFile('b.png', make_png_bytes(color='blue'), content_type='image/png')},
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
|
||||
)
|
||||
temps = list(TempUpload.objects.filter(kind='media').order_by('created_at'))
|
||||
self.client.post(reverse('library:asset_upload', args=['test-pack']), {
|
||||
f'caption_{temps[0].uuid}': 'First',
|
||||
f'caption_{temps[1].uuid}': 'Second',
|
||||
})
|
||||
captions = dict(ProjectAsset.objects.values_list('file__original_filename', 'caption'))
|
||||
self.assertEqual(captions, {'a.png': 'First', 'b.png': 'Second'})
|
||||
|
||||
def test_reject_non_media_file(self):
|
||||
self.gate()
|
||||
self.client.login(username='Alice', password='pw')
|
||||
@@ -311,7 +395,7 @@ class UGCGalleryTests(UGCMediaTestCase, UGCGatedTestCase):
|
||||
def test_gallery_upload_requires_pending_media(self):
|
||||
self.gate()
|
||||
self.client.login(username='Alice', password='pw')
|
||||
resp = self.client.post(reverse('library:asset_upload', args=['test-pack']), {'caption': 'none'})
|
||||
resp = self.client.post(reverse('library:asset_upload', args=['test-pack']))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(ProjectAsset.objects.count(), 0)
|
||||
|
||||
@@ -427,7 +511,7 @@ class UGCDraftTests(UGCMediaTestCase, UGCGatedTestCase):
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(TempUpload.objects.count(), 0)
|
||||
|
||||
def test_thumbnail_and_version_are_single_slot(self):
|
||||
def test_thumbnail_single_slot_version_multi(self):
|
||||
self.gate()
|
||||
self.client.login(username='Alice', password='pw')
|
||||
self.client.post(
|
||||
@@ -450,12 +534,12 @@ class UGCDraftTests(UGCMediaTestCase, UGCGatedTestCase):
|
||||
{'kind': 'version', 'file': SimpleUploadedFile('v2.zip', make_zip_bytes(), content_type='application/zip')},
|
||||
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
|
||||
)
|
||||
# Only the latest thumbnail + version remain; media unaffected.
|
||||
# Thumbnail is a single slot; version accepts multiple files.
|
||||
self.assertEqual(
|
||||
TempUpload.objects.filter(kind='thumbnail', status='pending').count(), 1
|
||||
)
|
||||
self.assertEqual(
|
||||
TempUpload.objects.filter(kind='version', status='pending').count(), 1
|
||||
TempUpload.objects.filter(kind='version', status='pending').count(), 2
|
||||
)
|
||||
|
||||
def test_temp_upload_delete(self):
|
||||
|
||||
@@ -12,6 +12,7 @@ urlpatterns = [
|
||||
path('packs/<slug:slug>/delete/', views.project_delete, name='project_delete'),
|
||||
path('packs/<slug:slug>/versions/upload/', views.version_upload, name='version_upload'),
|
||||
path('packs/<slug:slug>/versions/<int:version_id>/download/', views.version_download, name='version_download'),
|
||||
path('packs/<slug:slug>/versions/<int:version_id>/files/<uuid:file_uuid>/download/', views.version_file_download, name='version_file_download'),
|
||||
path('packs/<slug:slug>/gallery/upload/', views.asset_upload, name='asset_upload'),
|
||||
path('packs/<slug:slug>/gallery/<int:asset_id>/delete/', views.asset_delete, name='asset_delete'),
|
||||
path('packs/<slug:slug>/contributors/', views.contributors, name='contributors'),
|
||||
|
||||
+92
-42
@@ -25,6 +25,7 @@ from .models import (
|
||||
TagList,
|
||||
TempUpload,
|
||||
Version,
|
||||
VersionFile,
|
||||
slugify_tag,
|
||||
)
|
||||
from .storage import delete_file_index, move_file_index, store_temp_file
|
||||
@@ -307,7 +308,7 @@ def project_detail(request, slug):
|
||||
project = get_object_or_404(
|
||||
Project.objects.select_related('owner', 'thumbnail'), slug=slug,
|
||||
)
|
||||
versions = project.versions.select_related('file').all()
|
||||
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)
|
||||
@@ -323,7 +324,7 @@ def project_detail(request, slug):
|
||||
|
||||
DRAFT_KEYS = (
|
||||
'title', 'summary', 'category', 'description',
|
||||
'tags', 'caption', 'version_name', 'changelog',
|
||||
'tags', 'version_name', 'changelog',
|
||||
)
|
||||
|
||||
|
||||
@@ -344,6 +345,13 @@ def _pending_uploads(user):
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@@ -358,6 +366,35 @@ def _adopt_temp(upload, project_pk, subdir, kind):
|
||||
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 _finalize_version(project, version_name, changelog, temp_uploads):
|
||||
"""Adopt pending 'version' temp uploads into a new Version as VersionFiles."""
|
||||
version = Version.objects.create(
|
||||
project=project,
|
||||
version_name=version_name,
|
||||
changelog=changelog,
|
||||
)
|
||||
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'])
|
||||
return version
|
||||
|
||||
|
||||
def _form_values(*forms):
|
||||
out = {}
|
||||
for form in forms:
|
||||
@@ -376,9 +413,9 @@ def project_create(request):
|
||||
version_form = VersionForm(request.POST)
|
||||
if project_form.is_valid() and version_form.is_valid():
|
||||
data = project_form.cleaned_data
|
||||
version_file = _latest_pending(request.user, 'version')
|
||||
if version_file is None:
|
||||
version_form.add_error('version_name', 'Upload the version file before creating the project.')
|
||||
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']),
|
||||
@@ -396,24 +433,21 @@ def project_create(request):
|
||||
thumb.save(update_fields=['status'])
|
||||
project.save(update_fields=['thumbnail'])
|
||||
|
||||
version_index = _adopt_temp(version_file, project.pk, 'versions', 'version')
|
||||
version_file.status = 'used'
|
||||
version_file.save(update_fields=['status'])
|
||||
Version.objects.create(
|
||||
project=project,
|
||||
version_name=version_form.cleaned_data['version_name'],
|
||||
file=version_index,
|
||||
changelog=version_form.cleaned_data['changelog'],
|
||||
_finalize_version(
|
||||
project,
|
||||
version_form.cleaned_data['version_name'],
|
||||
version_form.cleaned_data['changelog'],
|
||||
version_files,
|
||||
)
|
||||
|
||||
caption = data.get('caption', '')
|
||||
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=caption, uploaded_by=request.user,
|
||||
caption=request.POST.get(f'caption_{temp.uuid}', '')[:128],
|
||||
uploaded_by=request.user,
|
||||
)
|
||||
temp.status = 'used'
|
||||
temp.save(update_fields=['status'])
|
||||
@@ -511,7 +545,8 @@ def project_delete(request, slug):
|
||||
indices = []
|
||||
if project.thumbnail_id:
|
||||
indices.append(project.thumbnail)
|
||||
indices += [v.file for v in project.versions.all() if v.file_id]
|
||||
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:
|
||||
@@ -531,41 +566,56 @@ def version_upload(request, slug):
|
||||
if request.method == 'POST':
|
||||
form = VersionForm(request.POST)
|
||||
if form.is_valid():
|
||||
version_file = _latest_pending(request.user, 'version')
|
||||
if version_file is None:
|
||||
form.add_error('version_name', 'Upload the version file before uploading.')
|
||||
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:
|
||||
index = _adopt_temp(version_file, project.pk, 'versions', 'version')
|
||||
version_file.status = 'used'
|
||||
version_file.save(update_fields=['status'])
|
||||
Version.objects.create(
|
||||
project=project,
|
||||
version_name=form.cleaned_data['version_name'],
|
||||
file=index,
|
||||
changelog=form.cleaned_data['changelog'],
|
||||
_finalize_version(
|
||||
project,
|
||||
form.cleaned_data['version_name'],
|
||||
form.cleaned_data['changelog'],
|
||||
version_files,
|
||||
)
|
||||
messages.success(request, f'Version {form.cleaned_data["version_name"]} uploaded.')
|
||||
return redirect('library:project_detail', slug=project.slug)
|
||||
|
||||
version_file = _latest_pending(request.user, 'version')
|
||||
version_files = _pending_kind(request.user, 'version')
|
||||
return render(request, 'library/version_upload.html', {
|
||||
'project': project,
|
||||
'version_form': form,
|
||||
'pending_uploads': [_serialize_temp(version_file)] if version_file else [],
|
||||
'pending_uploads': [_serialize_temp(u) for u in version_files],
|
||||
})
|
||||
|
||||
|
||||
def version_download(request, slug, version_id):
|
||||
version = get_object_or_404(
|
||||
Version.objects.select_related('file', 'project'),
|
||||
pk=version_id, project__slug=slug,
|
||||
)
|
||||
if version.file_id:
|
||||
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=[version.file.uuid]) + '?download=1'
|
||||
reverse('library:file_request', args=[file_index.uuid]) + '?download=1'
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -575,7 +625,6 @@ def asset_upload(request, slug):
|
||||
return HttpResponseForbidden('You do not have permission to add media.')
|
||||
|
||||
if request.method == 'POST':
|
||||
caption = request.POST.get('caption', '')
|
||||
media = TempUpload.objects.filter(user=request.user, status='pending', kind='media')
|
||||
if not media.exists():
|
||||
messages.error(request, 'No media uploaded yet.')
|
||||
@@ -585,7 +634,8 @@ def asset_upload(request, slug):
|
||||
index = _adopt_temp(temp, project.pk, 'gallery', 'asset')
|
||||
ProjectAsset.objects.create(
|
||||
project=project, file=index,
|
||||
caption=caption, uploaded_by=request.user,
|
||||
caption=request.POST.get(f'caption_{temp.uuid}', '')[:128],
|
||||
uploaded_by=request.user,
|
||||
)
|
||||
temp.status = 'used'
|
||||
temp.save(update_fields=['status'])
|
||||
@@ -595,7 +645,6 @@ def asset_upload(request, slug):
|
||||
|
||||
return render(request, 'library/asset_upload.html', {
|
||||
'project': project,
|
||||
'caption': request.POST.get('caption', ''),
|
||||
'pending_uploads': [_serialize_temp(u) for u in _pending_uploads(request.user)],
|
||||
})
|
||||
|
||||
@@ -707,9 +756,10 @@ def api_upload_temp(request):
|
||||
if kind == 'thumbnail' and not content_type.startswith('image/'):
|
||||
return JsonResponse({'error': 'The thumbnail must be an image.'}, status=400)
|
||||
|
||||
# Thumbnail and version are single slots: discard any earlier pending one.
|
||||
if kind in ('thumbnail', 'version'):
|
||||
for old in TempUpload.objects.filter(user=request.user, status='pending', kind=kind):
|
||||
# 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()
|
||||
|
||||
|
||||
@@ -2705,3 +2705,71 @@ a.deletelink {
|
||||
font-size: 0.75rem;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
/* ========== Project ID badge ========== */
|
||||
.project-id-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
vertical-align: middle;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* ========== Per-file version list ========== */
|
||||
.version-downloads {
|
||||
display: inline-block;
|
||||
margin-left: 12px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
.version-files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.version-file {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--md-sys-color-outline-variant);
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
max-width: 100%;
|
||||
}
|
||||
.version-file-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.version-file-icon {
|
||||
font-size: 1.4rem;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
.version-file-name {
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ========== Per-file caption on media previews ========== */
|
||||
.file-caption {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
width: calc(100% - 44px);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.7rem;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
.file-caption::placeholder {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
const autosaveStatus = document.getElementById('autosave-status');
|
||||
const csrf = getCookie('csrftoken');
|
||||
const csrfHeader = csrf ? { 'X-CSRFToken': csrf } : {};
|
||||
const FIELDS = ['title', 'summary', 'category', 'description', 'caption', 'version_name', 'changelog'];
|
||||
const FIELDS = ['title', 'summary', 'category', 'description', 'version_name', 'changelog'];
|
||||
|
||||
function setStatus(text, state) {
|
||||
autosaveStatus.innerHTML = text;
|
||||
|
||||
@@ -30,10 +30,10 @@
|
||||
zones[kind] = {
|
||||
zone,
|
||||
kind,
|
||||
single: kind === 'thumbnail' || kind === 'version',
|
||||
single: kind === 'thumbnail',
|
||||
drop: zone.querySelector('.drop-zone'),
|
||||
input: zone.querySelector('input[type=file]'),
|
||||
preview: zone.querySelector('.upload-preview, #media-preview'),
|
||||
preview: zone.querySelector('.file-preview-grid, .upload-preview'),
|
||||
batch: zone.querySelector('.upload-batch'),
|
||||
current: null,
|
||||
busy: false,
|
||||
@@ -78,6 +78,15 @@
|
||||
name.textContent = upload.filename || '';
|
||||
card.appendChild(name);
|
||||
|
||||
if (z.kind === 'media') {
|
||||
const cap = document.createElement('input');
|
||||
cap.type = 'text';
|
||||
cap.className = 'file-caption';
|
||||
cap.name = 'caption_' + upload.uuid;
|
||||
cap.placeholder = 'caption…';
|
||||
card.appendChild(cap);
|
||||
}
|
||||
|
||||
if (upload.removable !== false) {
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
|
||||
@@ -26,12 +26,6 @@
|
||||
<div class="file-preview-grid" id="media-preview"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="caption">Caption</label>
|
||||
<input type="text" id="caption" name="caption" maxlength="128"
|
||||
placeholder="Optional caption applied to all files" value="{{ caption }}">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary"><i class="fas fa-check"></i> Add to gallery</button>
|
||||
</div>
|
||||
|
||||
@@ -70,12 +70,12 @@
|
||||
<div class="drop-zone" role="button" tabindex="0">
|
||||
<div class="drop-zone-inner">
|
||||
<i class="fas fa-cloud-upload-alt drop-zone-icon"></i>
|
||||
<p class="drop-zone-text">Drag & drop the pack file</p>
|
||||
<p class="drop-zone-sub">or click to browse</p>
|
||||
<p class="drop-zone-text">Drag & drop the version files</p>
|
||||
<p class="drop-zone-sub">or click to browse — several at once</p>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="version-input" hidden>
|
||||
<div class="upload-preview" id="version-preview"></div>
|
||||
<input type="file" id="version-input" multiple hidden>
|
||||
<div class="file-preview-grid" id="version-preview"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ version_form.changelog.errors }}
|
||||
@@ -100,11 +100,6 @@
|
||||
</div>
|
||||
<div class="file-preview-grid" id="media-preview"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ project_form.caption.errors }}
|
||||
<label for="{{ project_form.caption.id_for_label }}">{{ project_form.caption.label }}</label>
|
||||
{{ project_form.caption }}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<div class="project-title-row">
|
||||
<h1>{{ project.title }}</h1>
|
||||
<span class="category-badge">{{ project.get_category_display }}</span>
|
||||
<span class="project-id-badge" title="Project ID">#{{ project.pk }}</span>
|
||||
</div>
|
||||
{% if project.summary %}<p class="project-summary">{{ project.summary }}</p>{% endif %}
|
||||
<div class="project-meta">
|
||||
@@ -85,14 +86,27 @@
|
||||
{% if forloop.first %}<span class="latest-badge">latest</span>{% endif %}
|
||||
</div>
|
||||
<span class="version-date"><i class="fas fa-clock"></i> {{ version.created_at|date:"M j, Y" }}</span>
|
||||
<span class="version-downloads"><i class="fas fa-download"></i> {{ version.downloads }}</span>
|
||||
</div>
|
||||
{% if version.changelog %}
|
||||
<div class="markdown-body version-changelog">{{ version.changelog|markdown }}</div>
|
||||
{% endif %}
|
||||
<a class="btn btn-primary btn-sm" href="{% url 'library:version_download' project.slug version.pk %}">
|
||||
<i class="fas fa-download"></i> {{ version.downloads }}
|
||||
<div class="version-files">
|
||||
{% for vf in version.files.all %}
|
||||
<div class="version-file">
|
||||
{% if vf.is_png %}
|
||||
<img class="version-file-thumb" src="{% url 'library:file_request' vf.file.uuid %}" alt="{{ vf.filename }}">
|
||||
{% else %}
|
||||
<i class="fas fa-file-archive version-file-icon"></i>
|
||||
{% endif %}
|
||||
<span class="version-file-name">{{ vf.filename }}</span>
|
||||
<a class="btn btn-primary btn-sm" href="{% url 'library:version_file_download' project.slug version.pk vf.file.uuid %}" title="Download {{ vf.filename }}">
|
||||
<i class="fas fa-download"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="empty-hint">No versions yet.</p>
|
||||
{% endfor %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% block title %}Edit {{ project.title }} - Packs Site{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1><i class="fas fa-pen"></i> Edit {{ project.title }}</h1>
|
||||
<h1><i class="fas fa-pen"></i> Edit {{ project.title }} <span class="project-id-badge" title="Project ID">#{{ project.pk }}</span></h1>
|
||||
<p><a href="{% url 'library:project_detail' project.slug %}">← Back to project</a></p>
|
||||
|
||||
<form method="post" class="card project-form create-form" id="project-form">
|
||||
|
||||
@@ -24,17 +24,17 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Version file</label>
|
||||
<label>Version files</label>
|
||||
<div class="upload-zone" data-upload="version">
|
||||
<div class="drop-zone" role="button" tabindex="0">
|
||||
<div class="drop-zone-inner">
|
||||
<i class="fas fa-cloud-upload-alt drop-zone-icon"></i>
|
||||
<p class="drop-zone-text">Drag & drop the pack file</p>
|
||||
<p class="drop-zone-sub">or click to browse</p>
|
||||
<p class="drop-zone-text">Drag & drop the version files</p>
|
||||
<p class="drop-zone-sub">or click to browse — several at once</p>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="version-input" hidden>
|
||||
<div class="upload-preview" id="version-preview"></div>
|
||||
<input type="file" id="version-input" multiple hidden>
|
||||
<div class="file-preview-grid" id="version-preview"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user