from django.db import models from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.utils.text import slugify class SlugHistory(models.Model): """ Model for tracking slug changes across all models that use slugs. Uses generic relations to work with any model. """ content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.CharField(max_length=50) # Using CharField to work with our custom IDs content_object = GenericForeignKey('content_type', 'object_id') old_slug = models.SlugField(max_length=200) created_at = models.DateTimeField(auto_now_add=True) class Meta: indexes = [ models.Index(fields=['content_type', 'object_id']), models.Index(fields=['old_slug']), ] verbose_name_plural = 'Slug histories' ordering = ['-created_at'] def __str__(self): return f"Old slug '{self.old_slug}' for {self.content_object}" class SluggedModel(models.Model): """ Abstract base model that provides slug functionality with history tracking. """ name = models.CharField(max_length=200) slug = models.SlugField(max_length=200, unique=True) class Meta: abstract = True def save(self, *args, **kwargs): # Get the current instance from DB if it exists if self.pk: try: old_instance = self.__class__.objects.get(pk=self.pk) # If slug has changed, save the old one to history if old_instance.slug != self.slug: SlugHistory.objects.create( content_type=ContentType.objects.get_for_model(self), object_id=getattr(self, self.get_id_field_name()), old_slug=old_instance.slug ) except self.__class__.DoesNotExist: pass # Generate slug if not set if not self.slug: self.slug = slugify(self.name) super().save(*args, **kwargs) def get_id_field_name(self): """ Returns the name of the read-only ID field for this model. Should be overridden by subclasses. """ raise NotImplementedError( "Subclasses of SluggedModel must implement get_id_field_name()" ) @classmethod def get_by_slug(cls, slug): """ Get an object by its current or historical slug. Returns (object, is_old_slug) tuple. """ try: # Try to get by current slug first return cls.objects.get(slug=slug), False except cls.DoesNotExist: # Try to find in slug history history = SlugHistory.objects.filter( content_type=ContentType.objects.get_for_model(cls), old_slug=slug ).order_by('-created_at').first() if history: return cls.objects.get( **{cls.get_id_field_name(): history.object_id} ), True raise cls.DoesNotExist( f"{cls.__name__} with slug '{slug}' does not exist" )