from django.db import models from django.contrib.auth.models import AbstractUser from django.core.validators import MinValueValidator, MaxValueValidator class User(AbstractUser): THEME_CHOICES = [ ('holy_light', 'Holy Light'), ('holy_dark', 'Holy Dark'), ('latte', 'Catppuccin Latte'), ('frappe', 'Catppuccin Frappé'), ('macchiato', 'Catppuccin Macchiato'), ('mocha', 'Catppuccin Mocha'), ('pink_pastel', 'Pink Pastel'), ('mint_pastel', 'Mint Pastel'), ('lavender_pastel', 'Lavender Pastel'), ('dracula', 'Dracula'), ('nord', 'Nordic Frost'), ('gruvbox', 'Gruvbox Retro'), ('oceanic', 'Oceanic Deep'), ('t0p_sai', 'SAI (T0P)'), ('t0p_trench', 'Trench (T0P)'), ('t0p_blurryface', 'Blurryface (T0P)'), ('t0p_clancy', 'Clancy (T0P)'), ] LANGUAGE_CHOICES = [ ('en', 'English'), ('de', 'Deutsch'), ] theme = models.CharField(max_length=20, choices=THEME_CHOICES, default='holy_light') language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default='en') avatar = models.ImageField(upload_to='avatars/', blank=True, null=True) pending_email = models.EmailField(max_length=254, blank=True, null=True) email_confirmation_code = models.CharField(max_length=6, blank=True, null=True) following = models.ManyToManyField('self', symmetrical=False, related_name='followers', blank=True) selected_banner = models.ForeignKey('Banner', on_delete=models.SET_NULL, null=True, blank=True, related_name='users') def save(self, *args, **kwargs): super().save(*args, **kwargs) if self.avatar: try: from PIL import Image img = Image.open(self.avatar.path) if img.height > 256 or img.width > 256 or img.format != 'JPEG': output_size = (256, 256) img.thumbnail(output_size) if img.mode in ("RGBA", "P"): img = img.convert("RGB") img.save(self.avatar.path, 'JPEG', quality=90) except Exception: pass def __str__(self): return self.username class Category(models.Model): name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) class Meta: verbose_name_plural = "Categories" ordering = ['name'] def __str__(self): return self.name class Flavor(models.Model): category = models.ForeignKey(Category, related_name='flavors', on_delete=models.CASCADE) name = models.CharField(max_length=100) # Removed unique=True here description = models.TextField(blank=True) image_url = models.URLField(max_length=1000, blank=True, null=True) image = models.ImageField(upload_to='flavors/', blank=True, null=True) shop_url = models.URLField(max_length=500, blank=True, null=True) is_available = models.BooleanField(default=True) is_legacy = models.BooleanField(default=False) external_id = models.BigIntegerField(unique=True, null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: # This allows "Erdbeere" in Iced Tea and "Erdbeere" in Milkshake constraints = [ models.UniqueConstraint(fields=['name', 'category'], name='unique_flavor_name_per_category') ] ordering = ['name'] def __str__(self): return f"{self.name} ({self.category.name})" @property def get_average_rating(self): ratings = self.ratings.all() if ratings.exists(): return sum(r.score for r in ratings) / ratings.count() return 0.0 @property def cached_image_url(self): if self.image: return self.image.url return self.image_url class Rating(models.Model): user = models.ForeignKey(User, related_name='ratings', on_delete=models.CASCADE) flavor = models.ForeignKey(Flavor, related_name='ratings', on_delete=models.CASCADE) score = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(10)]) comment = models.TextField(blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: unique_together = ('user', 'flavor') ordering = ['-created_at'] def __str__(self): return f"{self.user.username} - {self.flavor.name}: {self.score}" class Reply(models.Model): user = models.ForeignKey(User, related_name='replies', on_delete=models.CASCADE) rating = models.ForeignKey(Rating, related_name='replies', on_delete=models.CASCADE) text = models.TextField() created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['created_at'] def __str__(self): return f"Reply by {self.user.username} on {self.rating.id}" class Notification(models.Model): TYPE_CHOICES = [ ('reply', 'Reply'), ('mention', 'Mention'), ('follow', 'Follow'), ('ticket_new', 'New Ticket'), ('ticket_reply', 'Ticket Reply'), ('profile_comment', 'Profile Comment'), ] recipient = models.ForeignKey(User, related_name='notifications', on_delete=models.CASCADE) actor = models.ForeignKey(User, related_name='actions', on_delete=models.CASCADE) notification_type = models.CharField(max_length=20, choices=TYPE_CHOICES) rating = models.ForeignKey(Rating, on_delete=models.CASCADE, null=True, blank=True) reply = models.ForeignKey(Reply, on_delete=models.CASCADE, null=True, blank=True) ticket = models.ForeignKey('Ticket', on_delete=models.CASCADE, null=True, blank=True) profile_comment = models.ForeignKey('ProfileComment', on_delete=models.CASCADE, null=True, blank=True) is_read = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['-created_at'] def __str__(self): return f"{self.notification_type} for {self.recipient.username}" class UserIP(models.Model): user = models.ForeignKey(User, related_name='ips', on_delete=models.CASCADE) ip_address = models.GenericIPAddressField() last_login = models.DateTimeField(auto_now=True) class Meta: unique_together = ('user', 'ip_address') ordering = ['-last_login'] class Ticket(models.Model): STATUS_CHOICES = [ ('open', 'Open'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed'), ] user = models.ForeignKey(User, related_name='tickets', on_delete=models.CASCADE) subject = models.CharField(max_length=200) description = models.TextField() status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='open') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ['-updated_at'] class TicketMessage(models.Model): ticket = models.ForeignKey(Ticket, related_name='messages', on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE) text = models.TextField() created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['created_at'] class ProfileComment(models.Model): profile_owner = models.ForeignKey(User, related_name='profile_comments', on_delete=models.CASCADE) author = models.ForeignKey(User, related_name='authored_profile_comments', on_delete=models.CASCADE) text = models.TextField() created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['-created_at'] class Banner(models.Model): name = models.CharField(max_length=100) slug = models.SlugField(unique=True) description = models.TextField(blank=True) is_active = models.BooleanField(default=False, help_text="Designates this as the global default banner.") is_enabled = models.BooleanField(default=True, help_text="Allows users to select this banner.") settings = models.JSONField(default=dict, blank=True) schema = models.JSONField(default=list, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) def save(self, *args, **kwargs): if self.is_active: # Set all others to inactive Banner.objects.filter(is_active=True).exclude(pk=self.pk).update(is_active=False) elif not Banner.objects.filter(is_active=True).exclude(pk=self.pk).exists(): # If this was the only active banner, don't allow deactivating it # unless there's another one to take its place. self.is_active = True super().save(*args, **kwargs) def __str__(self): return self.name class SystemConfig(models.Model): """Singleton model for application-wide settings editable via UI.""" site_name = models.CharField(max_length=100, default="Holy Flavors Archive") maintenance_mode = models.BooleanField(default=False) allow_new_signups = models.BooleanField(default=True) require_email_verification = models.BooleanField(default=True) updated_at = models.DateTimeField(auto_now=True) def save(self, *args, **kwargs): self.pk = 1 # Force singleton super().save(*args, **kwargs) @classmethod def get_solo(cls): obj, _ = cls.objects.get_or_create(pk=1) return obj class Meta: verbose_name = "System Configuration" class Job(models.Model): """Tracks background jobs, their status and scheduling.""" JOB_TYPES = [ ('sync_flavors', 'Sync Holy Flavors'), ('cleanup_duplicates', 'Cleanup Duplicate Flavors'), ('backup_db', 'Database Backup'), ('seed_legacy', 'Seed Legacy Data'), ] STATUS_CHOICES = [ ('pending', 'Pending'), ('running', 'Running'), ('completed', 'Completed'), ('failed', 'Failed'), ] name = models.CharField(max_length=100, choices=JOB_TYPES) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') last_run = models.DateTimeField(null=True, blank=True) next_run = models.DateTimeField(null=True, blank=True) interval_hours = models.IntegerField(default=0, help_text="Set to 0 to disable periodic run.") last_output = models.TextField(blank=True) error_message = models.TextField(blank=True) def __str__(self): return f"{self.get_name_display()} ({self.status})"