392 lines
14 KiB
Python
392 lines
14 KiB
Python
import re
|
|
import uuid
|
|
|
|
from django.conf import settings
|
|
from django.db import models
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
|
|
|
|
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):
|
|
"""Tracks every user file so media stays organized and served only
|
|
through the gated FileRequest endpoint (never direct /media/ access)."""
|
|
|
|
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
|
owner = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='files',
|
|
)
|
|
kind = models.CharField(max_length=64, default='file', db_index=True)
|
|
stored_path = models.CharField(max_length=1024)
|
|
original_filename = models.CharField(max_length=255, blank=True, default='')
|
|
content_type = models.CharField(max_length=128, blank=True, default='')
|
|
size = models.IntegerField(default=0)
|
|
md5 = models.CharField(max_length=32, db_index=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
indexes = [
|
|
models.Index(fields=['owner', 'kind']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f'{self.kind} {self.uuid} ({self.owner})'
|
|
|
|
@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)
|
|
rating_score = models.IntegerField(default=0)
|
|
rating_count = models.PositiveIntegerField(default=0)
|
|
|
|
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='+',
|
|
)
|
|
auto = models.BooleanField(default=False)
|
|
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 release holding one or more VersionFile entries. Latest = newest."""
|
|
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='versions')
|
|
version_name = models.CharField(max_length=64)
|
|
changelog = models.TextField(blank=True, default='')
|
|
downloads = models.PositiveIntegerField(default=0)
|
|
pack_format = models.IntegerField(null=True, blank=True)
|
|
pack_description = models.TextField(blank=True, default='')
|
|
mods_manifest = models.JSONField(default=dict, blank=True)
|
|
plugins_manifest = models.JSONField(default=dict, blank=True)
|
|
skins_manifest = models.JSONField(default=dict, blank=True)
|
|
mod_manifest = models.JSONField(default=dict, blank=True)
|
|
animation_id = models.CharField(max_length=128, blank=True, default='', db_index=True)
|
|
animation_manifest = models.JSONField(default=dict, blank=True)
|
|
models_manifest = models.JSONField(default=dict, blank=True)
|
|
logical_manifest = models.JSONField(default=dict, blank=True)
|
|
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 VersionFile(models.Model):
|
|
"""One file belonging to a Version release. A version can hold several
|
|
files (e.g. multiple skins), each with its own download."""
|
|
KIND_CHOICES = [
|
|
('release', 'Release'),
|
|
('markdown', 'Markdown'),
|
|
('png', 'PNG'),
|
|
]
|
|
version = models.ForeignKey(Version, on_delete=models.CASCADE, related_name='files')
|
|
file = models.ForeignKey(FileIndex, on_delete=models.CASCADE, related_name='version_file')
|
|
kind = models.CharField(max_length=16, choices=KIND_CHOICES, default='release')
|
|
note = models.CharField(max_length=255, blank=True, default='')
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
ordering = ['created_at', 'pk']
|
|
|
|
def __str__(self):
|
|
return f'{self.version} — {self.file.original_filename}'
|
|
|
|
@property
|
|
def filename(self):
|
|
return self.file.original_filename
|
|
|
|
@property
|
|
def is_markdown(self):
|
|
return self.kind == 'markdown'
|
|
|
|
@property
|
|
def is_png(self):
|
|
return self.kind == 'png'
|
|
|
|
|
|
class ProjectAsset(models.Model):
|
|
"""Untracked gallery media (image/video/gif) displayed on the project page."""
|
|
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='assets')
|
|
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/')
|
|
|
|
@property
|
|
def is_gif(self):
|
|
return self.file.content_type == 'image/gif'
|
|
|
|
|
|
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}'
|
|
|
|
|
|
class Rating(models.Model):
|
|
"""One user's up/down vote on a project (Steam-style overall rating).
|
|
|
|
A user may hold a single vote per project; changing it edits this row.
|
|
``rating_score``/``rating_count`` on Project mirror the aggregate."""
|
|
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='ratings')
|
|
user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='ratings',
|
|
)
|
|
value = models.BooleanField(default=True) # True=positive/up, False=negative/down
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(fields=['project', 'user'], name='uniq_rating_project_user'),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f'{"+" if self.value else "-"}{self.user.username} on {self.project.slug}'
|
|
|
|
|
|
class Comment(models.Model):
|
|
"""A forum-style comment on a project. Unlimited; body is Markdown."""
|
|
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='comments')
|
|
user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='comments',
|
|
)
|
|
body = models.TextField()
|
|
created_at = models.DateTimeField(default=timezone.now)
|
|
updated_at = models.DateTimeField(default=timezone.now)
|
|
|
|
def save(self, *args, **kwargs):
|
|
if self._state.adding:
|
|
now = timezone.now()
|
|
self.created_at = now
|
|
self.updated_at = now
|
|
else:
|
|
self.updated_at = timezone.now()
|
|
super().save(*args, **kwargs)
|
|
|
|
def __str__(self):
|
|
return f'{self.user.username} on {self.project.slug}: {self.body[:40]}'
|