import os 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 from apps.core.video import VideoBlockMixin from .release_assets import ( FILE_FIELD_BY_ASSET_KEY, RELEASE_ASSET_KEY_BY_URL_FIELD, RELEASE_FILE_BY_URL_FIELD, RELEASE_FILE_HELP_TEXT, RELEASE_ORIGINAL_FILENAME_BY_ASSET, RELEASE_ORIGINAL_FILENAME_FIELDS, release_file_storage, release_linux_file_upload_to, release_macos_file_upload_to, release_source_file_upload_to, release_windows_file_upload_to, ) 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)"), ] RELEASE_CHANNEL_STABLE = "stable" RELEASE_CHANNEL_BETA = "beta" RELEASE_CHANNEL_RC = "rc" RELEASE_CHANNEL_PREVIEW = "preview" RELEASE_CHANNEL_NIGHTLY = "nightly" RELEASE_CHANNEL_CURRENT = "current" RELEASE_CHANNEL_PREVIOUS = "previous" RELEASE_CHANNEL_CHOICES = [ ("", "None"), (RELEASE_CHANNEL_STABLE, "Stable"), (RELEASE_CHANNEL_BETA, "Beta"), (RELEASE_CHANNEL_RC, "Release candidate"), (RELEASE_CHANNEL_PREVIEW, "Preview"), (RELEASE_CHANNEL_NIGHTLY, "Nightly"), (RELEASE_CHANNEL_CURRENT, "Current"), (RELEASE_CHANNEL_PREVIOUS, "Previous"), ] RELEASE_CHANNEL_LABELS = { RELEASE_CHANNEL_STABLE: "Stable", RELEASE_CHANNEL_BETA: "Beta", RELEASE_CHANNEL_RC: "Release candidate", RELEASE_CHANNEL_PREVIEW: "Preview", RELEASE_CHANNEL_NIGHTLY: "Nightly", RELEASE_CHANNEL_CURRENT: "Current", RELEASE_CHANNEL_PREVIOUS: "Previous", } 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.", ) downloads_section_link_url = models.URLField( blank=True, help_text="Optional link shown in the top-right corner of the downloads section.", ) downloads_section_link_text = models.CharField( max_length=100, blank=True, help_text="Label for the optional downloads section link.", ) 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.", ) downloads_section_link_url = models.URLField( blank=True, help_text="Optional link shown in the top-right corner of the downloads section.", ) downloads_section_link_text = models.CharField( max_length=100, blank=True, help_text="Label for the optional downloads section link.", ) 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) release_channel = models.CharField( max_length=20, choices=RELEASE_CHANNEL_CHOICES, blank=True, default="", help_text="Preset badge for this release (Stable, Beta, Previous, etc.).", ) channel_label = models.CharField( max_length=50, blank=True, help_text="Optional custom badge text. Overrides the preset channel label when set.", ) is_featured = models.BooleanField( default=False, help_text="Primary release shown at the top of the downloads section.", ) show_on_product_page = models.BooleanField( default=False, help_text="Also show this release on the product page (e.g. beta or previous version).", ) 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) windows_download_file = models.FileField( upload_to=release_windows_file_upload_to, storage=release_file_storage, blank=True, help_text=RELEASE_FILE_HELP_TEXT, ) macos_download_file = models.FileField( upload_to=release_macos_file_upload_to, storage=release_file_storage, blank=True, help_text=RELEASE_FILE_HELP_TEXT, ) linux_download_file = models.FileField( upload_to=release_linux_file_upload_to, storage=release_file_storage, blank=True, help_text=RELEASE_FILE_HELP_TEXT, ) source_code_file = models.FileField( upload_to=release_source_file_upload_to, storage=release_file_storage, blank=True, help_text=RELEASE_FILE_HELP_TEXT, ) windows_download_filename = models.CharField(max_length=255, blank=True) macos_download_filename = models.CharField(max_length=255, blank=True) linux_download_filename = models.CharField(max_length=255, blank=True) source_code_filename = models.CharField(max_length=255, 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}" @property def display_channel_label(self): custom = (self.channel_label or "").strip() if custom: return custom if self.release_channel: return RELEASE_CHANNEL_LABELS.get( self.release_channel, self.release_channel.replace("_", " ").title(), ) return "" @property def display_channel_css_modifier(self): if (self.channel_label or "").strip(): return "custom" return self.release_channel or "none" @property def is_previous_channel(self): if self.release_channel == RELEASE_CHANNEL_PREVIOUS: return True label = (self.channel_label or "").strip().lower() return label == "previous" def save(self, *args, **kwargs): for file_field, name_field in RELEASE_ORIGINAL_FILENAME_FIELDS.items(): field_file = getattr(self, file_field) if field_file and field_file._file is not None: setattr(self, name_field, os.path.basename(field_file.name)) elif not field_file: setattr(self, name_field, "") super().save(*args, **kwargs) def release_download_filename(self, asset): name_field = RELEASE_ORIGINAL_FILENAME_BY_ASSET.get(asset) if name_field: stored = (getattr(self, name_field, "") or "").strip() if stored: return stored file_field = FILE_FIELD_BY_ASSET_KEY.get(asset) if file_field: release_file = getattr(self, file_field) if release_file: return os.path.basename(release_file.name) return "download" def has_platform_asset(self, url_field_name): if (getattr(self, url_field_name) or "").strip(): return True file_field = RELEASE_FILE_BY_URL_FIELD.get(url_field_name) if not file_field: return False return bool(getattr(self, file_field)) def resolve_asset_url(self, url_field_name): url = (getattr(self, url_field_name) or "").strip() if url: return url file_field = RELEASE_FILE_BY_URL_FIELD.get(url_field_name) if not file_field or not getattr(self, file_field): return "" if not self.pk: return "" asset = RELEASE_ASSET_KEY_BY_URL_FIELD[url_field_name] return reverse( "products:release_asset_download", kwargs={"version_id": self.pk, "asset": asset}, ) def install_urls_for_specs(self, specs): out = [] for field_name, label, icon in specs: url = self.resolve_asset_url(field_name) if url: out.append( { "field": field_name, "label": label, "icon": icon, "url": url, "external": bool((getattr(self, field_name) or "").strip()), } ) return out def has_any_install_asset(self): return any(self.has_platform_asset(f[0]) for f in INSTALLABLE_PLATFORM_SPECS) @property def resolved_source_code_url(self): return self.resolve_asset_url("source_code_url") 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}" class ProductVideo(VideoBlockMixin, models.Model): main_product = models.ForeignKey( MainProduct, on_delete=models.CASCADE, related_name="videos", null=True, blank=True, ) sub_product = models.ForeignKey( SubProduct, on_delete=models.CASCADE, related_name="videos", null=True, blank=True, ) badge = models.CharField(max_length=100, blank=True) title = models.CharField(max_length=300, blank=True) description = models.TextField(blank=True) order = models.PositiveIntegerField(default=0) is_active = models.BooleanField(default=True) class Meta: ordering = ["order", "pk"] verbose_name = "Product Video" verbose_name_plural = "Product Videos" 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="product_video_exactly_one_parent", ), ] 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 product video must belong to exactly one main product or sub-product." ) self.clean_video_fields(require=True) def __str__(self): parent = self.sub_product or self.main_product label = self.title or self.badge or "Video" return f"{parent} › {label}"