fixed implementation of UGC content uploader
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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'}),
|
||||
)
|
||||
+110
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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}'
|
||||
|
||||
@@ -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
@@ -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'}])
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user