Files
tecvico-website/apps/products/models.py
T
2026-06-21 17:54:19 +03:30

601 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}"