Files
NoN-Site/nonpacks/library/models.py
T

382 lines
13 KiB
Python

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):
"""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(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f'{self.user.username} on {self.project.slug}: {self.body[:40]}'