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_/ 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]}'