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 from apps.core.history import TrackedModel import pghistory @pghistory.track() 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(TrackedModel): """ 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(TrackedModel.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: # Check pghistory first if available try: import pghistory.models history_entries = pghistory.models.Events.objects.filter( pgh_model=f"{cls._meta.app_label}.{cls._meta.model_name}", slug=slug ).order_by("-pgh_created_at") if history_entries: history_entry = history_entries.first() if history_entry: return cls.objects.get(id=history_entry.pgh_obj_id), True except (ImportError, AttributeError): pass # Try to find in manual slug history as fallback 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")