Files
com2care_website/apps/products/models.py
T
2026-05-25 15:57:24 +03:30

341 lines
12 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.
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.",
)
order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
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.",
)
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}"