initial commit
This commit is contained in:
@@ -0,0 +1,600 @@
|
||||
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}"
|
||||
Reference in New Issue
Block a user