from django.db import models from django.utils.translation import gettext_lazy as _ # 1. Define choices globally so they can be reused class StatusColorChoices(models.TextChoices): RED = 'red', _('Red') GREEN = 'green', _('Green') BLUE = 'blue', _('Blue') YELLOW = 'yellow', _('Yellow') ORANGE = 'orange', _('Orange') PURPLE = 'purple', _('Purple') GRAY = 'gray', _('Gray') # 2. Create the Abstract Mixin class ColorPaletteMixin(models.Model): """ An abstract base class that provides color choice fields and hex code properties to any model that inherits it. """ COLOR_PALETTE = { 'red': '#D33A3A', 'green': '#1DAC43', 'blue': '#5172E1', 'yellow': '#EDC130', 'orange': '#E67E22', 'purple': '#7C5CC4', 'gray': '#374151', } color = models.CharField( max_length=20, choices=StatusColorChoices.choices, verbose_name=_('color'), default=StatusColorChoices.GRAY ) class Meta: abstract = True @property def main_color_code(self): return self.COLOR_PALETTE.get(self.color, '#000000') from django.utils.text import slugify from django.http import HttpResponsePermanentRedirect from django.urls import reverse class LowercaseSlugMixin(models.Model): """ Mixin to automatically generate a lowercase, unicode-supporting unique slug from a source field (default: 'title') on save. """ slug_source_field = 'title' class Meta: abstract = True def save(self, *args, **kwargs): # 1. Generate slug if empty if not self.slug: source_value = getattr(self, self.slug_source_field, '') or '' base_slug = slugify(source_value, allow_unicode=True) or 'slug' base_slug = base_slug.lower() # Ensure uniqueness slug = base_slug counter = 1 model_class = self.__class__ # Exclude current instance from conflict check if it exists in database queryset = model_class.objects.all() if self.pk: queryset = queryset.exclude(pk=self.pk) while queryset.filter(slug=slug).exists(): slug = f"{base_slug}-{counter}" counter += 1 self.slug = slug else: # 2. Force existing slug to lowercase (handling both string and JSONField lists) if isinstance(self.slug, str): self.slug = self.slug.lower() elif isinstance(self.slug, list): for item in self.slug: if isinstance(item, dict) and "title" in item and item["title"]: item["title"] = str(item["title"]).lower() super().save(*args, **kwargs) class CanonicalSlugViewSetMixin: """ Mixin for DRF Views/ViewSets to enforce lowercase slugs and redirect with 301. """ def dispatch(self, request, *args, **kwargs): if request.method == 'GET': slug = kwargs.get('slug') if slug and any(char.isupper() for char in slug): resolved = request.resolver_match new_kwargs = {**resolved.kwargs, 'slug': slug.lower()} # Reconstruct path using reverse new_path = reverse(resolved.view_name, args=resolved.args, kwargs=new_kwargs) # Ensure trailing slash if not new_path.endswith('/'): new_path += '/' # Append query parameters if any query_string = request.META.get('QUERY_STRING') if query_string: new_path = f"{new_path}?{query_string}" return HttpResponsePermanentRedirect(new_path) return super().dispatch(request, *args, **kwargs) from utils.exceptions import ResourceGone class Gone410ViewMixin: """ Mixin for DRF detail Views/ViewSets to return 410 Gone instead of 404 for inactive/soft-deleted records. """ active_field_name = 'status' active_expected_value = True def get_object(self): obj = super().get_object() active_val = getattr(obj, self.active_field_name, None) if active_val != self.active_expected_value: raise ResourceGone() return obj