You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
138 lines
4.4 KiB
138 lines
4.4 KiB
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
|