from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.text import slugify from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_PLAIN, render_content INSTALLABLE_PLATFORM_SPECS = ( ("windows_download_url", "Windows", "images/icon-windows.svg"), ("macos_download_url", "macOS", "images/icon-macos.svg"), ("linux_download_url", "Linux", "images/icon-linux.svg"), ("source_code_url", "Source code", None), ) DISTRIBUTION_INSTALLABLE = "installable" DISTRIBUTION_PACKAGE = "package" DISTRIBUTION_CHOICES = [ (DISTRIBUTION_INSTALLABLE, "Installable application"), (DISTRIBUTION_PACKAGE, "Package (external / non-installable)"), ] class MainProduct(models.Model): name = models.CharField(max_length=200) slug = models.SlugField(unique=True, blank=True) short_description = models.CharField(max_length=300) description = models.TextField() description_format = models.CharField( max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN ) image = models.ImageField(upload_to="products/main/", blank=True, null=True) distribution = models.CharField( max_length=20, choices=DISTRIBUTION_CHOICES, default=DISTRIBUTION_INSTALLABLE, help_text="Only affects how releases appear on the public site.", ) package_resource_button_text = models.CharField( max_length=100, default="PyPI", help_text="Label for the package resource button on package releases.", ) package_source_button_text = models.CharField( max_length=100, default="Source", help_text="Label for the source code button on package releases.", ) order = models.PositiveIntegerField(default=0) is_active = models.BooleanField(default=True) show_on_homepage = models.BooleanField(default=False, help_text="Display this product in the homepage Products section.") homepage_order = models.PositiveIntegerField(default=0, help_text="Order within the homepage Products section.") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["order", "name"] verbose_name = "Main Product" verbose_name_plural = "Main Products" @property def rendered_description(self): return render_content(self.description, self.description_format) def __str__(self): return self.name def save(self, *args, **kwargs): if not self.slug: self.slug = slugify(self.name) super().save(*args, **kwargs) def get_absolute_url(self): return reverse("products:main_product_detail", kwargs={"main_slug": self.slug}) def get_versions_archive_url(self): return reverse( "products:main_product_versions", kwargs={"main_slug": self.slug}, ) class SubProduct(models.Model): DISTRIBUTION_INSTALLABLE = DISTRIBUTION_INSTALLABLE DISTRIBUTION_PACKAGE = DISTRIBUTION_PACKAGE DISTRIBUTION_CHOICES = DISTRIBUTION_CHOICES main_product = models.ForeignKey( MainProduct, on_delete=models.CASCADE, related_name="sub_products", ) name = models.CharField(max_length=200) slug = models.SlugField(blank=True) short_description = models.CharField(max_length=300) description = models.TextField() description_format = models.CharField( max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN ) image = models.ImageField(upload_to="products/sub/", blank=True, null=True) logo = models.ImageField(upload_to="products/sub_logos/", blank=True, null=True, help_text="Small logo shown as a corner badge on homepage product cards.") order = models.PositiveIntegerField(default=0) is_active = models.BooleanField(default=True) show_on_homepage = models.BooleanField(default=False, help_text="Display this sub-product in the homepage Products section.") homepage_order = models.PositiveIntegerField(default=0, help_text="Order within the homepage Products section.") distribution = models.CharField( max_length=20, choices=DISTRIBUTION_CHOICES, default=DISTRIBUTION_INSTALLABLE, help_text="Only affects how releases appear on the public site.", ) package_resource_button_text = models.CharField( max_length=100, default="PyPI", help_text="Label for the package resource button on package releases.", ) package_source_button_text = models.CharField( max_length=100, default="Source", help_text="Label for the source code button on package releases.", ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["order", "name"] unique_together = [["main_product", "slug"]] verbose_name = "Sub Product" verbose_name_plural = "Sub Products" @property def rendered_description(self): return render_content(self.description, self.description_format) def __str__(self): return f"{self.main_product.name} › {self.name}" def save(self, *args, **kwargs): if not self.slug: self.slug = slugify(self.name) super().save(*args, **kwargs) def get_absolute_url(self): return reverse( "products:sub_product_detail", kwargs={ "main_slug": self.main_product.slug, "sub_slug": self.slug, }, ) def get_versions_archive_url(self): return reverse( "products:sub_product_versions", kwargs={ "main_slug": self.main_product.slug, "sub_slug": self.slug, }, ) class Article(models.Model): main_product = models.ForeignKey( MainProduct, on_delete=models.CASCADE, related_name="articles", null=True, blank=True, ) sub_product = models.ForeignKey( SubProduct, on_delete=models.CASCADE, related_name="articles", null=True, blank=True, ) badge = models.CharField( max_length=100, blank=True, help_text="Optional label shown above the article title (e.g. 'Overview', 'Note').", ) title = models.CharField(max_length=300) description = models.TextField() description_format = models.CharField( max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN ) citation_count_display = models.PositiveSmallIntegerField( null=True, blank=True, help_text="Optional number shown in the dashed citation circle badge. Leave blank to show an empty circle (if a label is set) or hide the badge entirely.", ) citation_count_label = models.CharField( max_length=50, blank=True, default="", help_text="Optional label shown below the number in the citation circle badge (e.g. 'cited'). Leave blank to show no label.", ) order = models.PositiveIntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["order", "title"] verbose_name = "Article" verbose_name_plural = "Articles" constraints = [ models.CheckConstraint( check=( models.Q(sub_product__isnull=False, main_product__isnull=True) | models.Q(sub_product__isnull=True, main_product__isnull=False) ), name="article_exactly_one_parent", ), ] @property def rendered_description(self): return render_content(self.description, self.description_format) def clean(self): super().clean() has_main = self.main_product_id is not None has_sub = self.sub_product_id is not None if has_main == has_sub: raise ValidationError( "An article must belong to exactly one main product or sub-product." ) def __str__(self): return self.title class SubProductVersion(models.Model): main_product = models.ForeignKey( MainProduct, on_delete=models.CASCADE, related_name="versions", null=True, blank=True, ) sub_product = models.ForeignKey( SubProduct, on_delete=models.CASCADE, related_name="versions", null=True, blank=True, ) version = models.CharField(max_length=80) is_featured_stable = models.BooleanField( default=False, help_text="Highlighted as the main release on the product page.", ) windows_download_url = models.URLField(blank=True) macos_download_url = models.URLField(blank=True) linux_download_url = models.URLField(blank=True) source_code_url = models.URLField(blank=True) package_resource_url = models.URLField( blank=True, help_text="Shown on the site when set. For Resources it is the main Open link; for Installable it appears under the platform grid.", ) release_notes = models.TextField(blank=True) is_active = models.BooleanField(default=True) order = models.PositiveIntegerField(default=0) class Meta: ordering = ["order", "version", "pk"] verbose_name = "Release version" verbose_name_plural = "Release versions" constraints = [ models.CheckConstraint( check=( models.Q(sub_product__isnull=False, main_product__isnull=True) | models.Q(sub_product__isnull=True, main_product__isnull=False) ), name="release_version_exactly_one_parent", ), models.UniqueConstraint( fields=["sub_product", "version"], condition=models.Q(sub_product__isnull=False), name="release_version_unique_sub_product_version", ), models.UniqueConstraint( fields=["main_product", "version"], condition=models.Q(main_product__isnull=False), name="release_version_unique_main_product_version", ), ] def clean(self): super().clean() has_main = self.main_product_id is not None has_sub = self.sub_product_id is not None if has_main == has_sub: raise ValidationError( "A release version must belong to exactly one main product or sub-product." ) def __str__(self): parent = self.sub_product or self.main_product return f"{parent.name} v{self.version}" def install_urls_for_specs(self, specs): out = [] for field_name, label, icon in specs: url = getattr(self, field_name, "") or "" if url.strip(): out.append({"field": field_name, "label": label, "icon": icon, "url": url}) return out def has_any_install_asset(self): return any( (getattr(self, f[0]) or "").strip() for f in INSTALLABLE_PLATFORM_SPECS ) def has_package_link(self): return bool((self.package_resource_url or "").strip()) class ArticleSection(models.Model): article = models.ForeignKey( Article, on_delete=models.CASCADE, related_name="sections", ) title = models.CharField(max_length=200) value = models.TextField() value_format = models.CharField( max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN ) order = models.PositiveIntegerField(default=0) class Meta: ordering = ["order"] verbose_name = "Article Section" verbose_name_plural = "Article Sections" @property def rendered_value(self): return render_content(self.value, self.value_format) def __str__(self): return f"{self.article.title} › {self.title}" class ArticleCitation(models.Model): article = models.ForeignKey( Article, on_delete=models.CASCADE, related_name="citations", ) text = models.TextField(help_text="Full citation text.") url = models.URLField(blank=True, help_text="Optional link to the cited source.") order = models.PositiveIntegerField(default=0) class Meta: ordering = ["order", "pk"] verbose_name = "Article Citation" verbose_name_plural = "Article Citations" def __str__(self): return f"{self.article.title} — citation {self.order or self.pk}"