fixed implementation of UGC content uploader

This commit is contained in:
JakeBreath
2026-08-03 18:57:39 -05:00
parent 39e31d3980
commit 473b3c811b
40 changed files with 4137 additions and 87 deletions
+57 -1
View File
@@ -1,3 +1,59 @@
from django.contrib import admin
# Register your models here.
from .models import (
FileIndex,
Project,
ProjectAsset,
ProjectContributor,
Tag,
TagCategory,
TagList,
Version,
)
@admin.register(FileIndex)
class FileIndexAdmin(admin.ModelAdmin):
list_display = ('uuid', 'kind', 'owner', 'original_filename', 'size', 'created_at')
search_fields = ('uuid', 'original_filename', 'stored_path')
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
list_display = ('slug', 'title', 'category', 'owner', 'created_at', 'updated_at')
search_fields = ('slug', 'title', 'summary')
list_filter = ('category', 'created_at')
@admin.register(Version)
class VersionAdmin(admin.ModelAdmin):
list_display = ('project', 'version_name', 'downloads', 'created_at')
search_fields = ('project__title', 'version_name')
@admin.register(ProjectAsset)
class ProjectAssetAdmin(admin.ModelAdmin):
list_display = ('project', 'caption', 'uploaded_by', 'created_at')
@admin.register(ProjectContributor)
class ProjectContributorAdmin(admin.ModelAdmin):
list_display = ('project', 'user', 'added_by', 'added_at')
@admin.register(TagCategory)
class TagCategoryAdmin(admin.ModelAdmin):
list_display = ('slug', 'name', 'color')
@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
list_display = ('name', 'category', 'created_by', 'created_at')
list_filter = ('category',)
search_fields = ('name',)
@admin.register(TagList)
class TagListAdmin(admin.ModelAdmin):
list_display = ('project', 'tag', 'added_by', 'added_at')
search_fields = ('project__title', 'tag__name')
+64
View File
@@ -0,0 +1,64 @@
from django import forms
from .models import Project
class ProjectForm(forms.Form):
title = forms.CharField(
max_length=128,
widget=forms.TextInput(attrs={'placeholder': 'Project title'}),
)
summary = forms.CharField(
max_length=255,
required=False,
widget=forms.TextInput(attrs={'placeholder': 'One-line summary shown on cards'}),
)
category = forms.ChoiceField(choices=Project.CATEGORY_CHOICES)
description = forms.CharField(
required=False,
widget=forms.Textarea(attrs={
'rows': 8,
'placeholder': 'Write the description in Markdown…',
}),
)
thumbnail = forms.ImageField(required=False)
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(),
label='',
)
class VersionForm(forms.Form):
def __init__(self, *args, required_file=True, **kwargs):
# The create flow uploads the version file out-of-band (temp upload),
# so there the `file` field is optional in the bound form.
super().__init__(*args, **kwargs)
if not required_file:
self.fields['file'].required = False
version_name = forms.CharField(
max_length=64,
label='Version',
widget=forms.TextInput(attrs={'placeholder': 'e.g. 1.0.0'}),
)
file = forms.FileField(label='File')
changelog = forms.CharField(
required=False,
widget=forms.Textarea(attrs={
'rows': 4,
'placeholder': 'What changed in this version? (Markdown)',
}),
)
class ContributorForm(forms.Form):
username = forms.CharField(
max_length=150,
widget=forms.TextInput(attrs={'placeholder': 'Start typing a username…', 'autocomplete': 'off'}),
)
@@ -0,0 +1,110 @@
# Generated by Django 6.0.3 on 2026-08-03 20:12
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TagCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(max_length=64, unique=True)),
('name', models.CharField(max_length=64)),
('color', models.CharField(default='#666666', max_length=7)),
],
),
migrations.CreateModel(
name='Project',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(max_length=128, unique=True)),
('title', models.CharField(max_length=128)),
('summary', models.CharField(blank=True, default='', max_length=255)),
('description', models.TextField(blank=True, default='')),
('category', models.CharField(choices=[('skin', 'Skin'), ('non_pack', 'NoN Pack'), ('model', 'Model'), ('guide', 'Guide'), ('mod', 'Mod'), ('modpack', 'Modpack')], default='mod', max_length=32)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to=settings.AUTH_USER_MODEL)),
('thumbnail', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='project_thumb', to='library.fileindex')),
],
),
migrations.CreateModel(
name='ProjectAsset',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('caption', models.CharField(blank=True, default='', max_length=128)),
('created_at', models.DateTimeField(auto_now_add=True)),
('file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_asset', to='library.fileindex')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assets', to='library.project')),
('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at', '-pk'],
},
),
migrations.CreateModel(
name='Tag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_tags', to=settings.AUTH_USER_MODEL)),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='library.tagcategory')),
],
),
migrations.CreateModel(
name='TagList',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('added_at', models.DateTimeField(auto_now_add=True)),
('added_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tag_links', to='library.project')),
('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_links', to='library.tag')),
],
),
migrations.CreateModel(
name='Version',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('version_name', models.CharField(max_length=64)),
('changelog', models.TextField(blank=True, default='')),
('downloads', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='project_version', to='library.fileindex')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='library.project')),
],
options={
'ordering': ['-created_at', '-pk'],
},
),
migrations.CreateModel(
name='ProjectContributor',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('added_at', models.DateTimeField(auto_now_add=True)),
('added_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contributors', to='library.project')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ugc_contributions', to=settings.AUTH_USER_MODEL)),
],
options={
'constraints': [models.UniqueConstraint(fields=('project', 'user'), name='uniq_project_contributor')],
},
),
migrations.AddConstraint(
model_name='tag',
constraint=models.UniqueConstraint(fields=('name', 'category'), name='uniq_tag_name_category'),
),
migrations.AddConstraint(
model_name='taglist',
constraint=models.UniqueConstraint(fields=('project', 'tag'), name='uniq_project_tag'),
),
]
@@ -0,0 +1,33 @@
from django.db import migrations
CATEGORIES = [
('loader', 'Loader', '#3a86ff'),
('version', 'Version', '#8338ec'),
('general', 'General', '#666666'),
('creator', 'Creator', '#fb5607'),
('content', 'Content', '#d62828'),
('species', 'Species', '#06a77d'),
('non_version', 'NoN Version', '#9d4edd'),
]
def seed_categories(apps, schema_editor):
TagCategory = apps.get_model('library', 'TagCategory')
for slug, name, color in CATEGORIES:
TagCategory.objects.update_or_create(slug=slug, defaults={'name': name, 'color': color})
def unseed_categories(apps, schema_editor):
TagCategory = apps.get_model('library', 'TagCategory')
TagCategory.objects.filter(slug__in=[c[0] for c in CATEGORIES]).delete()
class Migration(migrations.Migration):
dependencies = [
('library', '0002_tagcategory_project_projectasset_tag_taglist_version_and_more'),
]
operations = [
migrations.RunPython(seed_categories, unseed_categories),
]
@@ -0,0 +1,41 @@
# Generated by Django 6.0.3 on 2026-08-03 23:20
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0003_seed_tag_categories'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ProjectDraft',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('data', models.JSONField(blank=True, default=dict)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='project_draft', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='TempUpload',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('kind', models.CharField(choices=[('thumbnail', 'Thumbnail'), ('version', 'Version file'), ('media', 'Gallery media')], db_index=True, max_length=16)),
('status', models.CharField(choices=[('pending', 'Pending'), ('used', 'Used'), ('discarded', 'Discarded')], db_index=True, default='pending', max_length=16)),
('created_at', models.DateTimeField(auto_now_add=True)),
('file', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='temp_upload', to='library.fileindex')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='temp_uploads', to=settings.AUTH_USER_MODEL)),
],
options={
'indexes': [models.Index(fields=['user', 'status', 'kind'], name='library_tem_user_id_fb5c3f_idx')],
},
),
]
+261
View File
@@ -1,7 +1,17 @@
import re
import uuid
from django.conf import settings
from django.db import models
from django.urls import reverse
def slugify_tag(value):
"""Normalize a user-entered tag name to its canonical form."""
value = (value or '').strip().lower()
value = re.sub(r'\s+', '_', value)
value = re.sub(r'[^a-z0-9_.+-]', '', value)
return value.strip('_')
class FileIndex(models.Model):
@@ -35,3 +45,254 @@ class FileIndex(models.Model):
@property
def file_path(self):
return self.stored_path
class TagCategory(models.Model):
"""Tag namespaces: Loader, Version, General, Creator, Content, Species, NoN Version."""
slug = models.SlugField(max_length=64, unique=True)
name = models.CharField(max_length=64)
color = models.CharField(max_length=7, default='#666666')
def __str__(self):
return self.name
class Tag(models.Model):
"""A single tag within a category. Auto-created on first use (e621-style)."""
name = models.CharField(max_length=64)
category = models.ForeignKey(
TagCategory,
on_delete=models.CASCADE,
related_name='tags',
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_tags',
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['name', 'category'], name='uniq_tag_name_category'),
]
def __str__(self):
return f'{self.category.slug}:{self.name}'
class Project(models.Model):
"""A UGC page: versioned downloads (tracked) + gallery media (untracked)."""
CATEGORY_CHOICES = [
('skin', 'Skin'),
('non_pack', 'NoN Pack'),
('model', 'Model'),
('guide', 'Guide'),
('mod', 'Mod'),
('modpack', 'Modpack'),
]
slug = models.SlugField(max_length=128, unique=True)
title = models.CharField(max_length=128)
summary = models.CharField(max_length=255, blank=True, default='')
description = models.TextField(blank=True, default='')
category = models.CharField(max_length=32, choices=CATEGORY_CHOICES, default='mod')
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='projects',
)
thumbnail = models.OneToOneField(
FileIndex,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='project_thumb',
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.title
@property
def thumbnail_url(self):
if self.thumbnail_id is None:
return None
return reverse('library:file_request', args=[self.thumbnail.uuid])
@property
def downloads(self):
return self.versions.aggregate(total=models.Sum('downloads'))['total'] or 0
def can_edit(self, user):
if not user.is_authenticated:
return False
if user.pk == self.owner_id:
return True
return self.contributors.filter(user=user).exists()
def sync_creator_tags(self, actor=None):
"""Ensure Creator tags exist + are attached for the owner and contributors."""
creator_cat = TagCategory.objects.filter(slug='creator').first()
if creator_cat is None:
return
actor = actor or self.owner
user_ids = {self.owner_id}
user_ids.update(self.contributors.values_list('user_id', flat=True))
for user_id in user_ids:
user = self.owner.__class__.objects.filter(pk=user_id).first()
if user is None:
continue
name = slugify_tag(user.username)
if not name:
continue
tag, _ = Tag.objects.get_or_create(
name=name, category=creator_cat, defaults={'created_by': user},
)
TagList.objects.get_or_create(project=self, tag=tag, defaults={'added_by': actor})
def detach_creator_tag(self, user):
creator_cat = TagCategory.objects.filter(slug='creator').first()
if creator_cat is None:
return
TagList.objects.filter(
project=self, tag__category=creator_cat, tag__name=slugify_tag(user.username),
).delete()
class ProjectContributor(models.Model):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='contributors')
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='ugc_contributions',
)
added_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='+',
)
added_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['project', 'user'], name='uniq_project_contributor'),
]
def __str__(self):
return f'{self.user.username} on {self.project.title}'
class TagList(models.Model):
"""Tag <-> Project link. Also records who applied the tag."""
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='tag_links')
tag = models.ForeignKey(Tag, on_delete=models.CASCADE, related_name='project_links')
added_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='+',
)
added_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['project', 'tag'], name='uniq_project_tag'),
]
def __str__(self):
return f'{self.project.title} #{self.tag}'
class Version(models.Model):
"""A tracked-file release. Every upload is stored; latest = newest created_at."""
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)
class Meta:
ordering = ['-created_at', '-pk']
def __str__(self):
return f'{self.project.title} {self.version_name}'
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')
file = models.ForeignKey(FileIndex, on_delete=models.CASCADE, related_name='project_asset')
caption = models.CharField(max_length=128, blank=True, default='')
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='+',
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at', '-pk']
def __str__(self):
return f'{self.project.title} asset'
@property
def file_url(self):
return reverse('library:file_request', args=[self.file.uuid])
@property
def is_video(self):
return self.file.content_type.startswith('video/')
class TempUpload(models.Model):
"""J621-style draft upload. Stored under media/uploads/user_<id>/<uuid><ext>
and tracked here until the project/gallery accepts it (status 'used') or it
is discarded. Previews are served through the gated file_request endpoint."""
KIND_CHOICES = [
('thumbnail', 'Thumbnail'),
('version', 'Version file'),
('media', 'Gallery media'),
]
STATUS_CHOICES = [
('pending', 'Pending'),
('used', 'Used'),
('discarded', 'Discarded'),
]
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='temp_uploads',
)
kind = models.CharField(max_length=16, choices=KIND_CHOICES, db_index=True)
file = models.ForeignKey(FileIndex, on_delete=models.CASCADE, related_name='temp_upload')
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='pending', db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
models.Index(fields=['user', 'status', 'kind']),
]
def __str__(self):
return f'{self.kind} {self.uuid} ({self.status})'
@property
def url(self):
return reverse('library:file_request', args=[self.file.uuid])
@property
def is_video(self):
return self.file.content_type.startswith('video/')
class ProjectDraft(models.Model):
"""Autosaved 'create project' form state. One per user; no explicit
Save Draft button — every keystroke is persisted in real time."""
user = models.OneToOneField(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='project_draft',
)
data = models.JSONField(default=dict, blank=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f'Draft for {self.user.username}'
+101
View File
@@ -0,0 +1,101 @@
"""Shared file-persistence helpers for UGC uploads.
Stored under MEDIA_ROOT/project_<pk>/... mirroring the avatar layout
(avatars/user_<id>/picture.jpeg). Draft uploads live under
MEDIA_ROOT/uploads/user_<id>/ until a project adopts them. All files are
indexed in FileIndex and served only through the gated file_request endpoint.
"""
import hashlib
import os
import uuid
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from .models import FileIndex
def _compute_md5_size(uploaded):
md5 = hashlib.md5()
size = 0
uploaded.seek(0)
while True:
chunk = uploaded.read(1024 * 1024)
if not chunk:
break
md5.update(chunk)
size += len(chunk)
uploaded.seek(0)
return md5.hexdigest(), size
def store_file(owner, kind, uploaded, project_pk, subdir='', original_filename=''):
"""Persist an uploaded file and index it.
Returns the FileIndex row. ``subdir`` is e.g. 'versions' or 'gallery'.
"""
name = getattr(uploaded, 'name', '') or original_filename or 'file'
content_type = getattr(uploaded, 'content_type', '') or 'application/octet-stream'
md5, size = _compute_md5_size(uploaded)
ext = os.path.splitext(name)[1] or ''
if subdir:
stored_path = f'project_{project_pk}/{subdir}/{uuid.uuid4().hex}{ext}'
else:
stored_path = f'project_{project_pk}/{uuid.uuid4().hex}{ext}'
actual_path = default_storage.save(stored_path, ContentFile(uploaded.read()))
return FileIndex.objects.create(
owner=owner,
kind=kind,
stored_path=actual_path,
original_filename=name,
content_type=content_type,
size=size,
md5=md5,
)
def store_temp_file(owner, kind, uploaded):
"""Persist a draft upload under uploads/user_<id>/ and return its FileIndex."""
name = getattr(uploaded, 'name', '') or 'file'
content_type = getattr(uploaded, 'content_type', '') or 'application/octet-stream'
md5, size = _compute_md5_size(uploaded)
ext = os.path.splitext(name)[1] or ''
stored_path = f'uploads/user_{owner.pk}/{uuid.uuid4().hex}{ext}'
actual_path = default_storage.save(stored_path, ContentFile(uploaded.read()))
return FileIndex.objects.create(
owner=owner,
kind=f'temp_{kind}',
stored_path=actual_path,
original_filename=name,
content_type=content_type,
size=size,
md5=md5,
)
def move_file_index(file_index, new_stored_path, kind=None):
"""Move an indexed file to a new stored path (chunked copy + delete old),
updating the index. Used when a project adopts a temp upload."""
if file_index.stored_path and file_index.stored_path != new_stored_path:
with default_storage.open(file_index.stored_path, 'rb') as src:
actual_path = default_storage.save(new_stored_path, src)
if actual_path != file_index.stored_path and default_storage.exists(file_index.stored_path):
default_storage.delete(file_index.stored_path)
file_index.stored_path = actual_path
if kind:
file_index.kind = kind
file_index.save(update_fields=['stored_path'] if not kind else ['stored_path', 'kind'])
return file_index
def delete_file_index(file_index):
"""Remove an indexed file from disk and DB (safe no-op when None)."""
if file_index is None:
return
if file_index.stored_path and default_storage.exists(file_index.stored_path):
default_storage.delete(file_index.stored_path)
file_index.delete()
+732 -2
View File
@@ -1,3 +1,733 @@
from django.test import TestCase
import io
import secrets
import shutil
import tempfile
# Create your tests here.
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
from profiles.models import ApiToken, UserProfile
from .models import (
Project,
ProjectAsset,
ProjectContributor,
ProjectDraft,
Tag,
TagCategory,
TagList,
TempUpload,
Version,
)
class UGCMediaTestCase(TestCase):
"""Base class that redirects uploaded media to a temp directory."""
@classmethod
def setUpClass(cls):
cls._media_root = tempfile.mkdtemp(prefix='packs_ugc_test_media_')
cls.override = override_settings(MEDIA_ROOT=cls._media_root)
cls.override.enable()
super().setUpClass()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
cls.override.disable()
shutil.rmtree(cls._media_root, ignore_errors=True)
class UGCGatedTestCase(TestCase):
"""Base class with a helper to authorize the client past the gate."""
def gate(self):
session = self.client.session
session['authorized'] = True
session.save()
def make_png_bytes(size=(8, 8), color='red'):
import PIL.Image
buf = io.BytesIO()
PIL.Image.new('RGB', size, color).save(buf, format='PNG')
return buf.getvalue()
def make_zip_bytes():
import zipfile
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr('pack.mcmeta', '{"pack": {"pack_format": 15}}')
return buf.getvalue()
class UGCProjectTests(UGCMediaTestCase, UGCGatedTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.alice)
self.bob = User.objects.create_user(username='Bob', password='pw')
UserProfile.objects.get_or_create(user=self.bob)
def _upload_temp(self, kind, name, content, content_type):
return self.client.post(
reverse('library:api_upload_temp'),
{'kind': kind, 'file': SimpleUploadedFile(name, content, content_type=content_type)},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
def _create_project(self, user, title='Test Pack', tags='general:furry content:threesome'):
self.gate()
self.client.login(username=user.username, password='pw')
self._upload_temp('thumbnail', 'thumb.png', make_png_bytes(), 'image/png')
self._upload_temp('version', 'pack.zip', make_zip_bytes(), 'application/zip')
data = {
'title': title,
'summary': 'A test pack.',
'category': 'mod',
'description': '# Hello\n\nWorld.',
'tags': tags,
'version_name': '1.0.0',
'changelog': 'First release.',
}
return self.client.post(reverse('library:project_create'), data)
def test_create_requires_login(self):
self.gate()
resp = self.client.get(reverse('library:project_create'))
self.assertEqual(resp.status_code, 302)
def test_create_project(self):
self.gate()
resp = self._create_project(self.alice)
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
project = Project.objects.get(slug='test-pack')
self.assertEqual(project.owner, self.alice)
self.assertEqual(project.category, 'mod')
# First version + thumbnail stored and adopted (moved under project_<pk>/).
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_'))
# Temp uploads marked used.
self.assertEqual(
TempUpload.objects.filter(user=self.alice, status='used').count(), 2
)
# Tags auto-created and linked.
names = set(TagList.objects.filter(project=project).values_list('tag__name', flat=True))
self.assertIn('furry', names)
self.assertIn('threesome', names)
self.assertIn('alice', names) # creator tag auto-synced
# Creator tag exists with category creator.
creator_cat = TagCategory.objects.get(slug='creator')
self.assertTrue(Tag.objects.filter(name='alice', category=creator_cat).exists())
def test_create_requires_version_file(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(reverse('library:project_create'), {
'title': 'No File',
'category': 'mod',
'version_name': '1.0.0',
})
self.assertEqual(resp.status_code, 200)
self.assertFalse(Project.objects.filter(slug='no-file').exists())
def test_create_adopts_media_and_caption(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')
resp = self.client.post(reverse('library:project_create'), {
'title': 'Media Pack',
'category': 'skin',
'version_name': '1.0.0',
'caption': 'Showcase',
})
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')
for asset in project.assets.all():
self.assertTrue(asset.file.stored_path.startswith('project_'))
def test_detail_requires_gate(self):
self._create_project(self.alice)
self.client.logout()
resp = self.client.get(reverse('library:project_detail', args=['test-pack']))
self.assertEqual(resp.status_code, 302) # gate redirects HTML pages
def test_detail_gated_ok(self):
self._create_project(self.alice)
self.client.logout()
self.gate()
resp = self.client.get(reverse('library:project_detail', args=['test-pack']))
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'Test Pack')
self.assertContains(resp, '<h1>Hello</h1>', html=True)
def test_detail_unknown_404(self):
self.gate()
resp = self.client.get(reverse('library:project_detail', args=['nope']))
self.assertEqual(resp.status_code, 404)
class UGCVersionTests(UGCMediaTestCase, UGCGatedTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.alice)
self.bob = User.objects.create_user(username='Bob', password='pw')
UserProfile.objects.get_or_create(user=self.bob)
self.project = Project.objects.create(
slug='test-pack', title='Test Pack', category='mod', owner=self.alice,
)
self.project.sync_creator_tags()
def test_upload_version_owner(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:version_upload', args=['test-pack']),
{
'version_name': '2.0.0',
'changelog': 'More stuff.',
'file': SimpleUploadedFile('pack2.zip', make_zip_bytes(), content_type='application/zip'),
},
)
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
self.assertEqual(self.project.versions.count(), 1)
self.assertEqual(self.project.versions.first().version_name, '2.0.0')
# Newest is latest (ordering).
self.assertTrue(self.project.versions.first().file_id)
def test_upload_version_non_contributor_denied(self):
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(
reverse('library:version_upload', args=['test-pack']),
{
'version_name': '2.0.0',
'file': SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip'),
},
)
self.assertEqual(resp.status_code, 403)
self.assertEqual(self.project.versions.count(), 0)
def test_contributor_can_upload(self):
ProjectContributor.objects.create(project=self.project, user=self.bob, added_by=self.alice)
self.project.sync_creator_tags()
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(
reverse('library:version_upload', args=['test-pack']),
{
'version_name': '2.0.0',
'file': SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip'),
},
)
self.assertEqual(resp.status_code, 302)
self.assertEqual(self.project.versions.count(), 1)
def test_download_bumps_counter_and_serves(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', file=index)
self.gate()
resp = self.client.get(reverse('library:version_download', args=['test-pack', version.pk]))
self.assertEqual(resp.status_code, 302)
version.refresh_from_db()
self.assertEqual(version.downloads, 1)
# Follow redirect → file served.
resp = self.client.get(resp.url)
self.assertEqual(resp.status_code, 200)
self.assertEqual(b''.join(resp.streaming_content), make_zip_bytes())
class UGCGalleryTests(UGCMediaTestCase, UGCGatedTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.alice)
self.bob = User.objects.create_user(username='Bob', password='pw')
UserProfile.objects.get_or_create(user=self.bob)
self.project = Project.objects.create(
slug='test-pack', title='Test Pack', category='skin', owner=self.alice,
)
def test_upload_image_asset(self):
self.gate()
self.client.login(username='Alice', password='pw')
self.client.post(
reverse('library:api_upload_temp'),
{'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'})
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_reject_non_media_file(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('evil.zip', make_zip_bytes(), content_type='application/zip')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 400)
self.assertEqual(ProjectAsset.objects.count(), 0)
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'})
self.assertEqual(resp.status_code, 200)
self.assertEqual(ProjectAsset.objects.count(), 0)
def test_asset_delete_removes_index(self):
from library.storage import store_file
index = store_file(
self.alice, 'asset',
SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png'),
self.project.pk, subdir='gallery',
)
asset = ProjectAsset.objects.create(project=self.project, file=index, uploaded_by=self.alice)
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(reverse('library:asset_delete', args=['test-pack', asset.pk]))
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
self.assertEqual(ProjectAsset.objects.count(), 0)
self.assertFalse(Project.objects.filter(pk=self.project.pk).exists() is False) # project intact
self.assertTrue(Project.objects.filter(pk=self.project.pk).exists())
def test_asset_delete_non_contributor_denied(self):
from library.storage import store_file
index = store_file(
self.alice, 'asset',
SimpleUploadedFile('pic.png', make_png_bytes(), content_type='image/png'),
self.project.pk, subdir='gallery',
)
asset = ProjectAsset.objects.create(project=self.project, file=index, uploaded_by=self.alice)
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(reverse('library:asset_delete', args=['test-pack', asset.pk]))
self.assertEqual(resp.status_code, 403)
self.assertEqual(ProjectAsset.objects.count(), 1)
class UGCDraftTests(UGCMediaTestCase, UGCGatedTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.alice)
def _post_json(self, url, payload):
import json
return self.client.post(url, json.dumps(payload), content_type='application/json')
def test_upload_api_requires_auth(self):
resp = self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('a.png', make_png_bytes(), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 401)
def test_temp_upload_media_accepts_video(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'media', 'file': SimpleUploadedFile('clip.webm', b'webm', content_type='video/webm')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.json()['uuid'])
upload = TempUpload.objects.get()
self.assertEqual(upload.kind, 'media')
self.assertEqual(upload.status, 'pending')
self.assertTrue(upload.file.stored_path.startswith('uploads/user_'))
def test_temp_upload_thumbnail_rejects_video(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'thumbnail', 'file': SimpleUploadedFile('clip.webm', b'webm', content_type='video/webm')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 400)
self.assertEqual(TempUpload.objects.count(), 0)
def test_thumbnail_and_version_are_single_slot(self):
self.gate()
self.client.login(username='Alice', password='pw')
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'thumbnail', 'file': SimpleUploadedFile('a.png', make_png_bytes(), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'thumbnail', 'file': SimpleUploadedFile('b.png', make_png_bytes(color='blue'), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'version', 'file': SimpleUploadedFile('v1.zip', make_zip_bytes(), content_type='application/zip')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.client.post(
reverse('library:api_upload_temp'),
{'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.
self.assertEqual(
TempUpload.objects.filter(kind='thumbnail', status='pending').count(), 1
)
self.assertEqual(
TempUpload.objects.filter(kind='version', status='pending').count(), 1
)
def test_temp_upload_delete(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(), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
upload = TempUpload.objects.get()
resp = self.client.post(
reverse('library:api_delete_temp', args=[upload.uuid]), HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(TempUpload.objects.count(), 0)
def test_delete_others_upload_forbidden(self):
User = get_user_model()
bob = User.objects.create_user(username='Bob', password='pw')
UserProfile.objects.get_or_create(user=bob)
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(), content_type='image/png')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
upload = TempUpload.objects.get()
self.client.logout()
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(
reverse('library:api_delete_temp', args=[upload.uuid]), HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
self.assertEqual(resp.status_code, 404)
self.assertEqual(TempUpload.objects.count(), 1)
def test_draft_save_and_restore(self):
self.gate()
self.client.login(username='Alice', password='pw')
self._post_json(reverse('library:api_draft'), {'data': {
'title': 'Draft Title',
'summary': 'Draft summary',
'category': 'mod',
'description': 'Hello draft',
'tags': 'species:dragon',
'version_name': '2.0.0',
}})
draft = ProjectDraft.objects.get(user=self.alice)
self.assertEqual(draft.data['title'], 'Draft Title')
resp = self.client.get(reverse('library:api_draft'))
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()['data']['title'], 'Draft Title')
def test_create_form_prefills_draft(self):
self.gate()
self.client.login(username='Alice', password='pw')
self._post_json(reverse('library:api_draft'), {'data': {'title': 'My Draft Pack'}})
resp = self.client.get(reverse('library:project_create'))
self.assertContains(resp, 'value="My Draft Pack"')
def test_create_clears_draft(self):
self.gate()
self.client.login(username='Alice', password='pw')
self._post_json(reverse('library:api_draft'), {'data': {'title': 'My Draft Pack'}})
self.client.post(
reverse('library:api_upload_temp'),
{'kind': 'version', 'file': SimpleUploadedFile('pack.zip', make_zip_bytes(), content_type='application/zip')},
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
resp = self.client.post(reverse('library:project_create'), {
'title': 'My Draft Pack', 'category': 'mod', 'version_name': '1.0.0',
})
self.assertRedirects(resp, reverse('library:project_detail', args=['my-draft-pack']))
self.assertFalse(ProjectDraft.objects.filter(user=self.alice).exists())
class UGCPermissionTests(UGCMediaTestCase, UGCGatedTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.alice)
self.bob = User.objects.create_user(username='Bob', password='pw')
UserProfile.objects.get_or_create(user=self.bob)
self.carol = User.objects.create_user(username='Carol', password='pw')
UserProfile.objects.get_or_create(user=self.carol)
self.project = Project.objects.create(
slug='test-pack', title='Test Pack', category='mod', owner=self.alice,
)
def test_edit_owner_ok(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:project_edit', args=['test-pack']),
{
'title': 'Renamed Pack',
'summary': 'New summary',
'description': 'Updated.',
'category': 'mod',
},
)
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
self.project.refresh_from_db()
self.assertEqual(self.project.title, 'Renamed Pack')
def test_edit_non_contributor_denied(self):
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(
reverse('library:project_edit', args=['test-pack']),
{'title': 'Hijacked', 'category': 'mod'},
)
self.assertEqual(resp.status_code, 403)
self.project.refresh_from_db()
self.assertEqual(self.project.title, 'Test Pack')
def test_edit_contributor_ok(self):
ProjectContributor.objects.create(project=self.project, user=self.bob, added_by=self.alice)
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(
reverse('library:project_edit', args=['test-pack']),
{
'title': 'Test Pack',
'summary': 'Edits by contributor.',
'description': 'Changed.',
'category': 'mod',
},
)
self.assertRedirects(resp, reverse('library:project_detail', args=['test-pack']))
self.project.refresh_from_db()
self.assertEqual(self.project.summary, 'Edits by contributor.')
def test_delete_owner_only(self):
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(reverse('library:project_delete', args=['test-pack']))
self.assertEqual(resp.status_code, 403)
self.assertTrue(Project.objects.filter(pk=self.project.pk).exists())
self.client.logout()
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(reverse('library:project_delete', args=['test-pack']))
self.assertRedirects(resp, reverse('library:browse'))
self.assertFalse(Project.objects.filter(pk=self.project.pk).exists())
def test_contributor_management_owner_only(self):
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(
reverse('library:contributors', args=['test-pack']),
{'action': 'add', 'username': 'Carol'},
)
self.assertEqual(resp.status_code, 403)
self.assertEqual(ProjectContributor.objects.count(), 0)
self.client.logout()
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(
reverse('library:contributors', args=['test-pack']),
{'action': 'add', 'username': 'Carol'},
)
self.assertRedirects(resp, reverse('library:contributors', args=['test-pack']))
self.assertTrue(
ProjectContributor.objects.filter(project=self.project, user=self.carol).exists()
)
def test_contributor_creator_tag_sync_and_detach(self):
ProjectContributor.objects.create(project=self.project, user=self.bob, added_by=self.alice)
self.project.sync_creator_tags()
creator_cat = TagCategory.objects.get(slug='creator')
self.assertTrue(Tag.objects.filter(name='alice', category=creator_cat).exists())
self.assertTrue(Tag.objects.filter(name='bob', category=creator_cat).exists())
self.assertEqual(
TagList.objects.filter(project=self.project, tag__category=creator_cat).count(), 2
)
# Remove Bob as a contributor → his creator tag detaches.
ProjectContributor.objects.filter(project=self.project, user=self.bob).delete()
self.project.detach_creator_tag(self.bob)
self.project.sync_creator_tags(actor=self.alice)
self.assertEqual(
TagList.objects.filter(project=self.project, tag__category=creator_cat).count(), 1
)
self.assertFalse(
TagList.objects.filter(project=self.project, tag__category=creator_cat, tag__name='bob').exists()
)
def test_edit_syncs_creator_tags(self):
ProjectContributor.objects.create(project=self.project, user=self.bob, added_by=self.alice)
self.gate()
self.client.login(username='Alice', password='pw')
self.client.post(
reverse('library:project_edit', args=['test-pack']),
{
'title': 'Test Pack',
'description': 'x',
'category': 'mod',
'tags': 'species:dragon',
},
)
creator_cat = TagCategory.objects.get(slug='creator')
names = set(TagList.objects.filter(project=self.project, tag__category=creator_cat).values_list('tag__name', flat=True))
self.assertIn('alice', names)
self.assertIn('bob', names)
# Manual species tag applied.
self.assertTrue(TagList.objects.filter(project=self.project, tag__name='dragon').exists())
class UGCBrowseTests(UGCGatedTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.alice)
self.bob = User.objects.create_user(username='Bob', password='pw')
UserProfile.objects.get_or_create(user=self.bob)
def _seed(self):
p1 = Project.objects.create(slug='furry-mod', title='Furry Mod', category='mod', owner=self.alice, summary='Dragon fun')
p2 = Project.objects.create(slug='skin-pack', title='Skin Pack', category='skin', owner=self.bob)
p1.sync_creator_tags()
# Tag p1: species:dragon, content:threesome. Tag p2: species:dragon only.
for cat_slug, name in [('species', 'dragon'), ('content', 'threesome')]:
cat = TagCategory.objects.get(slug=cat_slug)
tag = Tag.objects.get_or_create(name=name, category=cat)[0]
TagList.objects.create(project=p1, tag=tag, added_by=self.alice)
cat = TagCategory.objects.get(slug='species')
tag = Tag.objects.get_or_create(name='dragon', category=cat)[0]
TagList.objects.create(project=p2, tag=tag, added_by=self.bob)
return p1, p2
def test_browse_gated(self):
resp = self.client.get(reverse('library:browse'))
self.assertEqual(resp.status_code, 302)
def test_browse_lists_projects(self):
self._seed()
self.gate()
resp = self.client.get(reverse('library:browse'))
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'Furry Mod')
self.assertContains(resp, 'Skin Pack')
def test_browse_search(self):
self._seed()
self.gate()
resp = self.client.get(reverse('library:browse'), {'q': 'Furry'})
self.assertContains(resp, 'Furry Mod')
self.assertNotContains(resp, 'Skin Pack')
def test_browse_category_filter(self):
self._seed()
self.gate()
resp = self.client.get(reverse('library:browse'), {'category': 'skin'})
self.assertContains(resp, 'Skin Pack')
self.assertNotContains(resp, 'Furry Mod')
def test_browse_tag_filter_and(self):
self._seed()
self.gate()
resp = self.client.get(reverse('library:browse'), {'tag': ['dragon', 'threesome']})
self.assertContains(resp, 'Furry Mod')
self.assertNotContains(resp, 'Skin Pack')
def test_browse_tag_filter_single(self):
self._seed()
self.gate()
resp = self.client.get(reverse('library:browse'), {'tag': ['dragon']})
self.assertContains(resp, 'Furry Mod')
self.assertContains(resp, 'Skin Pack')
def test_browse_sort_recent(self):
self._seed()
self.gate()
resp = self.client.get(reverse('library:browse'))
self.assertEqual(resp.status_code, 200)
# Skin Pack created last → first card.
html = resp.content.decode()
self.assertLess(html.find('Skin Pack'), html.find('Furry Mod'))
def test_home_shows_real_projects(self):
self._seed()
self.gate()
resp = self.client.get(reverse('landing:home'))
self.assertContains(resp, 'Furry Mod')
self.assertContains(resp, 'Skin Pack')
class UGCApiTests(UGCMediaTestCase, UGCGatedTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.alice)
cat = TagCategory.objects.get(slug='species')
Tag.objects.create(name='dragon', category=cat, created_by=self.alice)
Tag.objects.create(name='dragoness', category=cat, created_by=self.alice)
self.token = secrets.token_urlsafe(48)
ApiToken.objects.create(user=self.alice, token=self.token, key_prefix=self.token[:8], label='test')
def test_tags_autocomplete_gated(self):
resp = self.client.get(reverse('library:api_tags_autocomplete'), {'q': 'dra'})
self.assertEqual(resp.status_code, 401)
def test_tags_autocomplete_with_token(self):
resp = self.client.get(
reverse('library:api_tags_autocomplete'), {'q': 'dra'},
HTTP_AUTHORIZATION=f'Bearer Alice {self.token}',
)
self.assertEqual(resp.status_code, 200)
self.assertJSONEqual(resp.content, [
{'name': 'dragon', 'category': 'species', 'category_name': 'Species', 'color': '#06a77d'},
{'name': 'dragoness', 'category': 'species', 'category_name': 'Species', 'color': '#06a77d'},
])
def test_users_autocomplete(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.get(reverse('library:api_users_autocomplete'), {'q': 'Ali'})
self.assertEqual(resp.status_code, 200)
self.assertJSONEqual(resp.content, [{'username': 'Alice'}])
+16 -1
View File
@@ -5,5 +5,20 @@ from . import views
app_name = 'library'
urlpatterns = [
path('files/<uuid:file_id>/', views.file_request, name='file_request'),
path('browse/', views.browse, name='browse'),
path('packs/create/', views.project_create, name='project_create'),
path('packs/<slug:slug>/', views.project_detail, name='project_detail'),
path('packs/<slug:slug>/edit/', views.project_edit, name='project_edit'),
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>/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'),
path('api/files/<uuid:file_id>/', views.file_request, name='file_request'),
path('api/uploads/', views.api_upload_temp, name='api_upload_temp'),
path('api/uploads/<uuid:upload_uuid>/delete/', views.api_delete_temp, name='api_delete_temp'),
path('api/draft/', views.api_draft, name='api_draft'),
path('api/tags/autocomplete/', views.api_tags_autocomplete, name='api_tags_autocomplete'),
path('api/users/autocomplete/', views.api_users_autocomplete, name='api_users_autocomplete'),
]
+599 -3
View File
@@ -1,11 +1,33 @@
import json
import posixpath
from pathlib import Path
from django.conf import settings
from django.http import FileResponse, Http404, HttpResponse, StreamingHttpResponse
from django.shortcuts import get_object_or_404
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 .models import FileIndex
from .forms import ContributorForm, ProjectForm, VersionForm
from .models import (
FileIndex,
Project,
ProjectAsset,
ProjectContributor,
ProjectDraft,
Tag,
TagCategory,
TagList,
TempUpload,
Version,
slugify_tag,
)
from .storage import delete_file_index, move_file_index, store_file, store_temp_file
def _open_indexed_file(file_index):
@@ -101,3 +123,577 @@ def file_request(request, file_id):
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 _apply_tags(project, raw_tags, actor):
"""Replace a project's manual tags (auto-creating new ones). Creator tags
are managed separately by sync_creator_tags."""
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)
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 non-creator tags back to 'category:name' form."""
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)
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.select_related('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)
context = {
'project': project,
'versions': versions,
'assets': assets,
'tags': tags,
'can_edit': can_edit,
}
return render(request, 'library/project_detail.html', context)
DRAFT_KEYS = (
'title', 'summary', 'category', 'description',
'tags', 'caption', '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 _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 _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(required_file=False)
if request.method == 'POST':
project_form = ProjectForm(request.POST)
version_form = VersionForm(request.POST, required_file=False)
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.')
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'])
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'],
)
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,
)
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
},
required_file=False,
)
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),
})
@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, request.FILES)
if form.is_valid():
data = form.cleaned_data
project.title = data['title']
project.summary = data['summary']
project.description = data['description']
project.category = data['category']
thumbnail = data.get('thumbnail')
if thumbnail:
delete_file_index(project.thumbnail)
project.thumbnail = store_file(
request.user, 'thumbnail', thumbnail, project.pk, subdir='',
)
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),
})
return render(request, 'library/project_edit.html', {
'project': project,
'project_form': form,
})
@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)
indices += [v.file for v in project.versions.all() if v.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, request.FILES)
if form.is_valid():
index = store_file(
request.user, 'version', form.cleaned_data['file'],
project.pk, subdir='versions',
)
Version.objects.create(
project=project,
version_name=form.cleaned_data['version_name'],
file=index,
changelog=form.cleaned_data['changelog'],
)
messages.success(request, f'Version {form.cleaned_data["version_name"]} uploaded.')
return redirect('library:project_detail', slug=project.slug)
return render(request, 'library/version_upload.html', {
'project': project,
'version_form': form,
})
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:
Version.objects.filter(pk=version.pk).update(downloads=F('downloads') + 1)
return redirect(
reverse('library:file_request', args=[version.file.uuid]) + '?download=1'
)
raise Http404
@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':
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.')
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=caption, 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,
'caption': request.POST.get('caption', ''),
'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.')
delete_file_index(asset.file)
asset.delete()
return redirect('library:project_detail', slug=project.slug)
@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 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):
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)