initial commit
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.core.admin_video import video_admin_preview
|
||||
from apps.core.video import VIDEO_ADMIN_FIELDSET
|
||||
|
||||
from .models import Article, ArticleCitation, ArticleSection, MainProduct, ProductVideo, SubProduct, SubProductVersion
|
||||
|
||||
|
||||
class ArticleCitationInline(admin.TabularInline):
|
||||
model = ArticleCitation
|
||||
extra = 1
|
||||
fields = ("text", "url", "order")
|
||||
ordering = ("order",)
|
||||
|
||||
|
||||
class ArticleSectionInline(admin.TabularInline):
|
||||
model = ArticleSection
|
||||
extra = 1
|
||||
fields = ("title", "value_format", "value", "order")
|
||||
ordering = ("order",)
|
||||
|
||||
|
||||
class SubProductArticleInline(admin.StackedInline):
|
||||
model = Article
|
||||
fk_name = "sub_product"
|
||||
extra = 0
|
||||
fields = ("badge", "title", "description", "order")
|
||||
ordering = ("order",)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class MainProductArticleInline(admin.StackedInline):
|
||||
model = Article
|
||||
fk_name = "main_product"
|
||||
extra = 0
|
||||
fields = ("badge", "title", "description", "order")
|
||||
ordering = ("order",)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
RELEASE_VERSION_INLINE_FIELDS = (
|
||||
"version",
|
||||
"release_channel",
|
||||
"channel_label",
|
||||
"is_featured",
|
||||
"show_on_product_page",
|
||||
"windows_download_url",
|
||||
"macos_download_url",
|
||||
"linux_download_url",
|
||||
"source_code_url",
|
||||
"package_resource_url",
|
||||
"release_notes",
|
||||
"is_active",
|
||||
"order",
|
||||
)
|
||||
|
||||
|
||||
class SubProductVersionInline(admin.TabularInline):
|
||||
model = SubProductVersion
|
||||
fk_name = "sub_product"
|
||||
extra = 0
|
||||
ordering = ("order", "version")
|
||||
fields = RELEASE_VERSION_INLINE_FIELDS
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class MainProductVersionInline(admin.TabularInline):
|
||||
model = SubProductVersion
|
||||
fk_name = "main_product"
|
||||
extra = 0
|
||||
ordering = ("order", "version")
|
||||
fields = RELEASE_VERSION_INLINE_FIELDS
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class ProductVideoInline(admin.StackedInline):
|
||||
model = ProductVideo
|
||||
fk_name = "main_product"
|
||||
extra = 0
|
||||
fields = (
|
||||
"badge",
|
||||
"title",
|
||||
"description_format",
|
||||
"description",
|
||||
"video_source",
|
||||
"video_url",
|
||||
"video_file",
|
||||
"video_poster",
|
||||
"video_size",
|
||||
"video_aspect_ratio",
|
||||
"video_styled_background",
|
||||
"order",
|
||||
"is_active",
|
||||
)
|
||||
ordering = ("order",)
|
||||
|
||||
|
||||
class SubProductInline(admin.StackedInline):
|
||||
model = SubProduct
|
||||
extra = 0
|
||||
fields = ("name", "slug", "distribution", "short_description", "image", "logo", "show_on_homepage", "homepage_order", "order", "is_active")
|
||||
ordering = ("order",)
|
||||
show_change_link = True
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
|
||||
|
||||
@admin.register(MainProduct)
|
||||
class MainProductAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "show_on_homepage", "homepage_order", "order", "is_active", "created_at")
|
||||
list_filter = ("is_active", "show_on_homepage")
|
||||
search_fields = ("name", "description")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
|
||||
inlines = [MainProductArticleInline, ProductVideoInline, MainProductVersionInline, SubProductInline]
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"name",
|
||||
"slug",
|
||||
"short_description",
|
||||
"distribution",
|
||||
"package_resource_button_text",
|
||||
"package_source_button_text",
|
||||
"downloads_section_link_text",
|
||||
"downloads_section_link_url",
|
||||
)
|
||||
},
|
||||
),
|
||||
("Description", {"fields": ("description_format", "description")}),
|
||||
("Media", {"fields": ("image",)}),
|
||||
("Homepage", {"fields": ("show_on_homepage", "homepage_order")}),
|
||||
("Settings", {"fields": ("order", "is_active")}),
|
||||
)
|
||||
|
||||
|
||||
class SubProductVideoInline(admin.StackedInline):
|
||||
model = ProductVideo
|
||||
fk_name = "sub_product"
|
||||
extra = 0
|
||||
fields = (
|
||||
"badge",
|
||||
"title",
|
||||
"description_format",
|
||||
"description",
|
||||
"video_source",
|
||||
"video_url",
|
||||
"video_file",
|
||||
"video_poster",
|
||||
"video_size",
|
||||
"video_aspect_ratio",
|
||||
"video_styled_background",
|
||||
"order",
|
||||
"is_active",
|
||||
)
|
||||
ordering = ("order",)
|
||||
|
||||
|
||||
@admin.register(SubProduct)
|
||||
class SubProductAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"main_product",
|
||||
"distribution",
|
||||
"show_on_homepage",
|
||||
"homepage_order",
|
||||
"order",
|
||||
"is_active",
|
||||
"created_at",
|
||||
)
|
||||
list_filter = ("is_active", "distribution", "main_product", "show_on_homepage")
|
||||
search_fields = ("name", "description", "main_product__name")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
|
||||
raw_id_fields = ("main_product",)
|
||||
inlines = [SubProductArticleInline, SubProductVideoInline, SubProductVersionInline]
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"main_product",
|
||||
"name",
|
||||
"slug",
|
||||
"distribution",
|
||||
"short_description",
|
||||
"package_resource_button_text",
|
||||
"package_source_button_text",
|
||||
"downloads_section_link_text",
|
||||
"downloads_section_link_url",
|
||||
)
|
||||
},
|
||||
),
|
||||
("Description", {"fields": ("description_format", "description")}),
|
||||
("Media", {"fields": ("image", "logo")}),
|
||||
("Homepage", {"fields": ("show_on_homepage", "homepage_order")}),
|
||||
("Settings", {"fields": ("order", "is_active")}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Article)
|
||||
class ArticleAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "parent", "order", "created_at")
|
||||
list_filter = ("main_product", "sub_product__main_product")
|
||||
search_fields = ("title", "description", "main_product__name", "sub_product__name")
|
||||
list_editable = ("order",)
|
||||
raw_id_fields = ("main_product", "sub_product")
|
||||
inlines = [ArticleSectionInline, ArticleCitationInline]
|
||||
fieldsets = (
|
||||
(None, {"fields": ("main_product", "sub_product", "badge", "title")}),
|
||||
("Description", {"fields": ("description_format", "description")}),
|
||||
("Citation Badge", {"fields": ("citation_count_display", "citation_count_label"), "description": "Number and optional label for the dashed citation circle badge. Both are optional — a label without a number shows an empty circle with the label; neither hides the badge entirely."}),
|
||||
("Settings", {"fields": ("order",)}),
|
||||
)
|
||||
|
||||
@admin.display(description="Parent")
|
||||
def parent(self, obj):
|
||||
if obj.main_product_id:
|
||||
return obj.main_product
|
||||
if obj.sub_product_id:
|
||||
return obj.sub_product
|
||||
return "—"
|
||||
|
||||
|
||||
@admin.register(ProductVideo)
|
||||
class ProductVideoAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "parent", "video_source", "video_size", "order", "is_active")
|
||||
list_filter = ("video_source", "is_active", "main_product", "sub_product__main_product")
|
||||
list_editable = ("order", "is_active")
|
||||
search_fields = ("title", "description", "video_url", "main_product__name", "sub_product__name")
|
||||
raw_id_fields = ("main_product", "sub_product")
|
||||
readonly_fields = ("video_preview",)
|
||||
fieldsets = (
|
||||
(None, {"fields": ("main_product", "sub_product", "badge", "title", "description_format", "description")}),
|
||||
VIDEO_ADMIN_FIELDSET,
|
||||
("Settings", {"fields": ("order", "is_active")}),
|
||||
)
|
||||
|
||||
@admin.display(description="Parent")
|
||||
def parent(self, obj):
|
||||
if obj.main_product_id:
|
||||
return obj.main_product
|
||||
if obj.sub_product_id:
|
||||
return obj.sub_product
|
||||
return "—"
|
||||
|
||||
@admin.display(description="Preview")
|
||||
def video_preview(self, obj):
|
||||
return video_admin_preview(obj)
|
||||
|
||||
|
||||
@admin.register(ArticleSection)
|
||||
class ArticleSectionAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "article", "value_format", "order")
|
||||
search_fields = ("title", "value", "article__title")
|
||||
list_editable = ("order",)
|
||||
raw_id_fields = ("article",)
|
||||
fieldsets = (
|
||||
(None, {"fields": ("article", "title")}),
|
||||
("Content", {"fields": ("value_format", "value")}),
|
||||
("Settings", {"fields": ("order",)}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(SubProductVersion)
|
||||
class SubProductVersionAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"parent",
|
||||
"version",
|
||||
"release_channel",
|
||||
"channel_label",
|
||||
"is_featured",
|
||||
"show_on_product_page",
|
||||
"is_active",
|
||||
"order",
|
||||
)
|
||||
list_filter = ("is_active", "release_channel", "is_featured", "show_on_product_page", "main_product", "sub_product__main_product")
|
||||
search_fields = ("main_product__name", "sub_product__name", "version")
|
||||
list_editable = ("is_active", "order")
|
||||
raw_id_fields = ("main_product", "sub_product")
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"description": (
|
||||
"Set a preset channel badge (Stable, Beta, Previous, etc.) or write a custom "
|
||||
"label. Mark one release as Primary (featured) for the main download block. "
|
||||
"Enable “Show on product page” for additional inline channels such as beta or "
|
||||
"previous versions."
|
||||
),
|
||||
"fields": (
|
||||
"main_product",
|
||||
"sub_product",
|
||||
"version",
|
||||
"release_channel",
|
||||
"channel_label",
|
||||
"is_featured",
|
||||
"show_on_product_page",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Installable downloads",
|
||||
{
|
||||
"description": (
|
||||
"For each platform, set an external URL, upload a file, both, or neither. "
|
||||
"When a URL is set it is used on the site; otherwise an uploaded file is served. "
|
||||
"To delete an uploaded file from the server, open this release, check Clear next to the file, and Save."
|
||||
),
|
||||
"fields": (
|
||||
"windows_download_url",
|
||||
"windows_download_file",
|
||||
"macos_download_url",
|
||||
"macos_download_file",
|
||||
"linux_download_url",
|
||||
"linux_download_file",
|
||||
"source_code_url",
|
||||
"source_code_file",
|
||||
),
|
||||
},
|
||||
),
|
||||
("Package link", {"fields": ("package_resource_url",)}),
|
||||
("Details", {"fields": ("release_notes",)}),
|
||||
("Settings", {"fields": ("is_active", "order")}),
|
||||
)
|
||||
|
||||
@admin.display(description="Parent")
|
||||
def parent(self, obj):
|
||||
if obj.main_product_id:
|
||||
return obj.main_product
|
||||
if obj.sub_product_id:
|
||||
return obj.sub_product
|
||||
return "—"
|
||||
@@ -0,0 +1,10 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ProductsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.products"
|
||||
verbose_name = "Products"
|
||||
|
||||
def ready(self):
|
||||
from . import signals # noqa: F401
|
||||
@@ -0,0 +1,93 @@
|
||||
# Generated by Django 5.2.13 on 2026-04-27 09:07
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Article',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=300)),
|
||||
('description', models.TextField()),
|
||||
('order', models.PositiveIntegerField(default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Article',
|
||||
'verbose_name_plural': 'Articles',
|
||||
'ordering': ['order', 'title'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MainProduct',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('slug', models.SlugField(blank=True, unique=True)),
|
||||
('short_description', models.CharField(max_length=300)),
|
||||
('description', models.TextField()),
|
||||
('image', models.ImageField(blank=True, null=True, upload_to='products/main/')),
|
||||
('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)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Main Product',
|
||||
'verbose_name_plural': 'Main Products',
|
||||
'ordering': ['order', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ArticleSection',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('value', models.TextField()),
|
||||
('order', models.PositiveIntegerField(default=0)),
|
||||
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='products.article')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Article Section',
|
||||
'verbose_name_plural': 'Article Sections',
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SubProduct',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('slug', models.SlugField(blank=True)),
|
||||
('short_description', models.CharField(max_length=300)),
|
||||
('description', models.TextField()),
|
||||
('image', models.ImageField(blank=True, null=True, upload_to='products/sub/')),
|
||||
('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)),
|
||||
('main_product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sub_products', to='products.mainproduct')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Sub Product',
|
||||
'verbose_name_plural': 'Sub Products',
|
||||
'ordering': ['order', 'name'],
|
||||
'unique_together': {('main_product', 'slug')},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='article',
|
||||
name='sub_product',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='articles', to='products.subproduct'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.0.2 on 2026-05-14 04:53
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SubProductRelease',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('platform', models.CharField(choices=[('windows', 'Windows'), ('macos', 'macOS'), ('linux', 'Linux')], max_length=20)),
|
||||
('release_type', models.CharField(choices=[('stable', 'Stable'), ('previous', 'Previous')], default='stable', max_length=20)),
|
||||
('version', models.CharField(help_text='e.g. 2.1.0', max_length=50)),
|
||||
('download_url', models.URLField()),
|
||||
('release_notes', models.TextField(blank=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('order', models.PositiveIntegerField(default=0)),
|
||||
('sub_product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='releases', to='products.subproduct')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Release',
|
||||
'verbose_name_plural': 'Releases',
|
||||
'ordering': ['release_type', 'platform', 'order'],
|
||||
'unique_together': {('sub_product', 'platform', 'release_type')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.0.2 on 2026-05-14 05:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0002_subproductrelease'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='article',
|
||||
name='description_format',
|
||||
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='articlesection',
|
||||
name='value_format',
|
||||
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='mainproduct',
|
||||
name='description_format',
|
||||
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subproduct',
|
||||
name='description_format',
|
||||
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,145 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def migrate_legacy_releases_to_versions(apps, schema_editor):
|
||||
OldRelease = apps.get_model("products", "SubProductRelease")
|
||||
Version = apps.get_model("products", "SubProductVersion")
|
||||
|
||||
PLATFORM_MAP = {
|
||||
"windows": "windows_download_url",
|
||||
"macos": "macos_download_url",
|
||||
"linux": "linux_download_url",
|
||||
}
|
||||
|
||||
sub_ids = (
|
||||
OldRelease.objects.values_list("sub_product_id", flat=True).distinct().order_by()
|
||||
)
|
||||
|
||||
for sub_id in sub_ids:
|
||||
used_labels = set()
|
||||
for release_type in ("stable", "previous"):
|
||||
slab = OldRelease.objects.filter(
|
||||
sub_product_id=sub_id, release_type=release_type
|
||||
).order_by("order", "pk")
|
||||
if not slab.exists():
|
||||
continue
|
||||
urls = {}
|
||||
labels = []
|
||||
orders = []
|
||||
notes = []
|
||||
any_active = False
|
||||
for r in slab:
|
||||
orders.append(r.order)
|
||||
if r.is_active:
|
||||
any_active = True
|
||||
f = PLATFORM_MAP.get(r.platform)
|
||||
if f and r.download_url:
|
||||
urls[f] = r.download_url
|
||||
labels.append(r.version or "")
|
||||
if (r.release_notes or "").strip():
|
||||
notes.append((r.release_notes or "").strip())
|
||||
vn = next((x for x in labels if x), None) or "1.0"
|
||||
if vn in used_labels:
|
||||
suffix = "older" if release_type == "previous" else "alternate"
|
||||
candidate = f"{vn} ({suffix})"
|
||||
n = 2
|
||||
while candidate in used_labels:
|
||||
candidate = f"{vn} ({suffix} {n})"
|
||||
n += 1
|
||||
vn = candidate
|
||||
used_labels.add(vn)
|
||||
Version.objects.create(
|
||||
sub_product_id=sub_id,
|
||||
version=vn,
|
||||
is_featured_stable=(release_type == "stable"),
|
||||
windows_download_url=urls.get("windows_download_url", ""),
|
||||
macos_download_url=urls.get("macos_download_url", ""),
|
||||
linux_download_url=urls.get("linux_download_url", ""),
|
||||
source_code_url="",
|
||||
package_resource_url="",
|
||||
release_notes="\n\n".join(dict.fromkeys(notes)),
|
||||
is_active=any_active,
|
||||
order=min(orders) if orders else 0,
|
||||
)
|
||||
|
||||
|
||||
def noop_reverse(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("products", "0003_article_description_format_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="subproduct",
|
||||
name="distribution",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("installable", "Installable application"),
|
||||
("package", "Package (external / non-installable)"),
|
||||
],
|
||||
default="installable",
|
||||
help_text="Only affects how releases appear on the public site.",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SubProductVersion",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("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="For package-type modules: external link for this release.",
|
||||
),
|
||||
),
|
||||
("release_notes", models.TextField(blank=True)),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
("order", models.PositiveIntegerField(default=0)),
|
||||
(
|
||||
"sub_product",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="versions",
|
||||
to="products.subproduct",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Release version",
|
||||
"verbose_name_plural": "Release versions",
|
||||
"ordering": ["order", "version", "pk"],
|
||||
"unique_together": {("sub_product", "version")},
|
||||
},
|
||||
),
|
||||
migrations.RunPython(migrate_legacy_releases_to_versions, noop_reverse),
|
||||
migrations.DeleteModel(
|
||||
name="SubProductRelease",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("products", "0004_distribution_and_versions"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="article",
|
||||
name="badge",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="Optional label shown above the article title (e.g. 'Overview', 'Note').",
|
||||
max_length=100,
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
# Generated by Django 5.0.2 on 2026-05-17 14:59
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0005_article_badge'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='subproduct',
|
||||
name='homepage_order',
|
||||
field=models.PositiveIntegerField(default=0, help_text='Order within the homepage Products section.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subproduct',
|
||||
name='logo',
|
||||
field=models.ImageField(blank=True, help_text='Small logo shown as a corner badge on homepage product cards.', null=True, upload_to='products/sub_logos/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subproduct',
|
||||
name='show_on_homepage',
|
||||
field=models.BooleanField(default=False, help_text='Display this sub-product in the homepage Products section.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subproductversion',
|
||||
name='package_resource_url',
|
||||
field=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.'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ArticleCitation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('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)),
|
||||
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='citations', to='products.article')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Article Citation',
|
||||
'verbose_name_plural': 'Article Citations',
|
||||
'ordering': ['order', 'pk'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2026-05-17 15:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0006_subproduct_logo_homepage_citations'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='article',
|
||||
name='citation_count_display',
|
||||
field=models.PositiveSmallIntegerField(blank=True, help_text='Optional number shown in the dashed citation circle badge at the bottom of the article card. Leave blank to hide the badge.', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.0.2 on 2026-05-20 12:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0007_article_citation_count_display'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='article',
|
||||
name='citation_count_label',
|
||||
field=models.CharField(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.", max_length=50),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='article',
|
||||
name='citation_count_display',
|
||||
field=models.PositiveSmallIntegerField(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.', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.0.2 on 2026-05-23 10:11
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0008_article_citation_count_label'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='article',
|
||||
name='main_product',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='articles', to='products.mainproduct'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='article',
|
||||
name='sub_product',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='articles', to='products.subproduct'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='article',
|
||||
constraint=models.CheckConstraint(check=models.Q(models.Q(('main_product__isnull', True), ('sub_product__isnull', False)), models.Q(('main_product__isnull', False), ('sub_product__isnull', True)), _connector='OR'), name='article_exactly_one_parent'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 5.0.2 on 2026-05-23 10:31
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0009_article_main_product'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='subproductversion',
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='mainproduct',
|
||||
name='distribution',
|
||||
field=models.CharField(choices=[('installable', 'Installable application'), ('package', 'Package (external / non-installable)')], default='installable', help_text='Only affects how releases appear on the public site.', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subproductversion',
|
||||
name='main_product',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='products.mainproduct'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subproductversion',
|
||||
name='sub_product',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='products.subproduct'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='subproductversion',
|
||||
constraint=models.CheckConstraint(check=models.Q(models.Q(('main_product__isnull', True), ('sub_product__isnull', False)), models.Q(('main_product__isnull', False), ('sub_product__isnull', True)), _connector='OR'), name='release_version_exactly_one_parent'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='subproductversion',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('sub_product__isnull', False)), fields=('sub_product', 'version'), name='release_version_unique_sub_product_version'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='subproductversion',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('main_product__isnull', False)), fields=('main_product', 'version'), name='release_version_unique_main_product_version'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.0.2 on 2026-05-26 12:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0010_release_version_main_product'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='mainproduct',
|
||||
name='homepage_order',
|
||||
field=models.PositiveIntegerField(default=0, help_text='Order within the homepage Products section.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='mainproduct',
|
||||
name='show_on_homepage',
|
||||
field=models.BooleanField(default=False, help_text='Display this product in the homepage Products section.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def copy_subproduct_homepage_flags(apps, schema_editor):
|
||||
SubProduct = apps.get_model("products", "SubProduct")
|
||||
for sub in (
|
||||
SubProduct.objects.filter(show_on_homepage=True)
|
||||
.select_related("main_product")
|
||||
.order_by("homepage_order", "order")
|
||||
):
|
||||
main = sub.main_product
|
||||
if not main.show_on_homepage:
|
||||
main.show_on_homepage = True
|
||||
main.homepage_order = sub.homepage_order
|
||||
main.save(update_fields=["show_on_homepage", "homepage_order"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("products", "0011_mainproduct_homepage_order_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(copy_subproduct_homepage_flags, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.0.2 on 2026-05-31 09:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0012_migrate_homepage_visibility_to_mainproduct'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='mainproduct',
|
||||
name='package_resource_button_text',
|
||||
field=models.CharField(default='PyPI', help_text='Label for the package resource button on package releases.', max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='mainproduct',
|
||||
name='package_source_button_text',
|
||||
field=models.CharField(default='Source', help_text='Label for the source code button on package releases.', max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subproduct',
|
||||
name='package_resource_button_text',
|
||||
field=models.CharField(default='PyPI', help_text='Label for the package resource button on package releases.', max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subproduct',
|
||||
name='package_source_button_text',
|
||||
field=models.CharField(default='Source', help_text='Label for the source code button on package releases.', max_length=100),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.0.2 on 2026-05-31 09:42
|
||||
|
||||
import apps.products.release_assets
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0013_package_release_button_labels'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='subproductversion',
|
||||
name='linux_download_file',
|
||||
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', upload_to=apps.products.release_assets.release_file_upload_to),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subproductversion',
|
||||
name='macos_download_file',
|
||||
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', upload_to=apps.products.release_assets.release_file_upload_to),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subproductversion',
|
||||
name='source_code_file',
|
||||
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', upload_to=apps.products.release_assets.release_file_upload_to),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subproductversion',
|
||||
name='windows_download_file',
|
||||
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', upload_to=apps.products.release_assets.release_file_upload_to),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 5.0.2 on 2026-05-31 09:52
|
||||
|
||||
import apps.products.release_assets
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0014_release_download_files'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='subproductversion',
|
||||
name='linux_download_filename',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subproductversion',
|
||||
name='macos_download_filename',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subproductversion',
|
||||
name='source_code_filename',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='subproductversion',
|
||||
name='windows_download_filename',
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subproductversion',
|
||||
name='linux_download_file',
|
||||
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_linux_file_upload_to),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subproductversion',
|
||||
name='macos_download_file',
|
||||
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_macos_file_upload_to),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subproductversion',
|
||||
name='source_code_file',
|
||||
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_source_file_upload_to),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subproductversion',
|
||||
name='windows_download_file',
|
||||
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_windows_file_upload_to),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("products", "0015_release_original_filenames"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="mainproduct",
|
||||
name="downloads_section_link_text",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Label for the optional downloads section link.",
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="mainproduct",
|
||||
name="downloads_section_link_url",
|
||||
field=models.URLField(
|
||||
blank=True,
|
||||
help_text="Optional link shown in the top-right corner of the downloads section.",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="subproduct",
|
||||
name="downloads_section_link_text",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Label for the optional downloads section link.",
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="subproduct",
|
||||
name="downloads_section_link_url",
|
||||
field=models.URLField(
|
||||
blank=True,
|
||||
help_text="Optional link shown in the top-right corner of the downloads section.",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.2.13 on 2026-06-07 13:49
|
||||
|
||||
import apps.products.release_assets
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0016_downloads_section_link'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='subproductversion',
|
||||
name='linux_download_file',
|
||||
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. To remove an uploaded file, check Clear, then Save — the file is deleted from the server. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_linux_file_upload_to),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subproductversion',
|
||||
name='macos_download_file',
|
||||
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. To remove an uploaded file, check Clear, then Save — the file is deleted from the server. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_macos_file_upload_to),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subproductversion',
|
||||
name='source_code_file',
|
||||
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. To remove an uploaded file, check Clear, then Save — the file is deleted from the server. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_source_file_upload_to),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subproductversion',
|
||||
name='windows_download_file',
|
||||
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. To remove an uploaded file, check Clear, then Save — the file is deleted from the server. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_windows_file_upload_to),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 5.2.13 on 2026-06-08 12:42
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0017_alter_subproductversion_linux_download_file_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ProductVideo',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('video_source', models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20)),
|
||||
('video_url', models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500)),
|
||||
('video_file', models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/')),
|
||||
('video_poster', models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/')),
|
||||
('video_size', models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10)),
|
||||
('video_aspect_ratio', models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10)),
|
||||
('badge', models.CharField(blank=True, max_length=100)),
|
||||
('title', models.CharField(blank=True, max_length=300)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('order', models.PositiveIntegerField(default=0)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('main_product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='videos', to='products.mainproduct')),
|
||||
('sub_product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='videos', to='products.subproduct')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Product Video',
|
||||
'verbose_name_plural': 'Product Videos',
|
||||
'ordering': ['order', 'pk'],
|
||||
'constraints': [models.CheckConstraint(condition=models.Q(models.Q(('main_product__isnull', True), ('sub_product__isnull', False)), models.Q(('main_product__isnull', False), ('sub_product__isnull', True)), _connector='OR'), name='product_video_exactly_one_parent')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.13 on 2026-06-08 13:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0018_video_support'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='productvideo',
|
||||
name='video_styled_background',
|
||||
field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.13 on 2026-06-08 13:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0019_video_styled_background'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='productvideo',
|
||||
name='description_format',
|
||||
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_featured_stable_to_channels(apps, schema_editor):
|
||||
Version = apps.get_model("products", "SubProductVersion")
|
||||
for version in Version.objects.filter(is_featured_stable=True):
|
||||
version.is_featured = True
|
||||
if not version.release_channel:
|
||||
version.release_channel = "stable"
|
||||
version.save(update_fields=["is_featured", "release_channel"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("products", "0020_video_description_format"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="subproductversion",
|
||||
name="channel_label",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Optional custom badge text. Overrides the preset channel label when set.",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="subproductversion",
|
||||
name="is_featured",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="Primary release shown at the top of the downloads section.",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="subproductversion",
|
||||
name="release_channel",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("", "None"),
|
||||
("stable", "Stable"),
|
||||
("beta", "Beta"),
|
||||
("rc", "Release candidate"),
|
||||
("preview", "Preview"),
|
||||
("nightly", "Nightly"),
|
||||
("current", "Current"),
|
||||
("previous", "Previous"),
|
||||
],
|
||||
default="",
|
||||
help_text="Preset badge for this release (Stable, Beta, Previous, etc.).",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="subproductversion",
|
||||
name="show_on_product_page",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="Also show this release on the product page (e.g. beta or previous version).",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_featured_stable_to_channels, migrations.RunPython.noop),
|
||||
migrations.RemoveField(
|
||||
model_name="subproductversion",
|
||||
name="is_featured_stable",
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,84 @@
|
||||
import os
|
||||
|
||||
from django.core.files.storage import FileSystemStorage
|
||||
from django.utils.text import get_valid_filename
|
||||
|
||||
|
||||
class ReleaseFileStorage(FileSystemStorage):
|
||||
def get_available_name(self, name, max_length=None):
|
||||
return name
|
||||
|
||||
|
||||
release_file_storage = ReleaseFileStorage()
|
||||
|
||||
RELEASE_FILE_BY_URL_FIELD = {
|
||||
"windows_download_url": "windows_download_file",
|
||||
"macos_download_url": "macos_download_file",
|
||||
"linux_download_url": "linux_download_file",
|
||||
"source_code_url": "source_code_file",
|
||||
}
|
||||
|
||||
RELEASE_ASSET_KEY_BY_URL_FIELD = {
|
||||
"windows_download_url": "windows",
|
||||
"macos_download_url": "macos",
|
||||
"linux_download_url": "linux",
|
||||
"source_code_url": "source",
|
||||
}
|
||||
|
||||
URL_FIELD_BY_ASSET_KEY = {
|
||||
v: k for k, v in RELEASE_ASSET_KEY_BY_URL_FIELD.items()
|
||||
}
|
||||
|
||||
FILE_FIELD_BY_ASSET_KEY = {
|
||||
asset: RELEASE_FILE_BY_URL_FIELD[url_field]
|
||||
for url_field, asset in RELEASE_ASSET_KEY_BY_URL_FIELD.items()
|
||||
}
|
||||
|
||||
RELEASE_ASSET_KEYS = frozenset(FILE_FIELD_BY_ASSET_KEY.keys())
|
||||
|
||||
RELEASE_FILE_HELP_TEXT = (
|
||||
"Optional. Served from this site when the matching URL above is empty. "
|
||||
"To remove an uploaded file, check Clear, then Save — the file is deleted from the server. "
|
||||
"Any file type and size are allowed; large uploads may require web server limits."
|
||||
)
|
||||
|
||||
RELEASE_ORIGINAL_FILENAME_FIELDS = {
|
||||
"windows_download_file": "windows_download_filename",
|
||||
"macos_download_file": "macos_download_filename",
|
||||
"linux_download_file": "linux_download_filename",
|
||||
"source_code_file": "source_code_filename",
|
||||
}
|
||||
|
||||
RELEASE_ORIGINAL_FILENAME_BY_ASSET = {
|
||||
RELEASE_ASSET_KEY_BY_URL_FIELD[url_field]: RELEASE_ORIGINAL_FILENAME_FIELDS[file_field]
|
||||
for url_field, file_field in RELEASE_FILE_BY_URL_FIELD.items()
|
||||
}
|
||||
|
||||
|
||||
def _release_file_path(instance, filename, platform):
|
||||
if instance.sub_product_id:
|
||||
base = f"{instance.sub_product.main_product.slug}/{instance.sub_product.slug}"
|
||||
else:
|
||||
base = instance.main_product.slug
|
||||
version_part = (instance.version or "release").replace("/", "-")
|
||||
safe_name = get_valid_filename(os.path.basename(filename))
|
||||
return f"releases/{base}/{version_part}/{platform}/{safe_name}"
|
||||
|
||||
|
||||
def release_windows_file_upload_to(instance, filename):
|
||||
return _release_file_path(instance, filename, "windows")
|
||||
|
||||
|
||||
def release_macos_file_upload_to(instance, filename):
|
||||
return _release_file_path(instance, filename, "macos")
|
||||
|
||||
|
||||
def release_linux_file_upload_to(instance, filename):
|
||||
return _release_file_path(instance, filename, "linux")
|
||||
|
||||
|
||||
def release_source_file_upload_to(instance, filename):
|
||||
return _release_file_path(instance, filename, "source")
|
||||
|
||||
|
||||
release_file_upload_to = release_windows_file_upload_to
|
||||
@@ -0,0 +1,161 @@
|
||||
from .models import DISTRIBUTION_INSTALLABLE, DISTRIBUTION_PACKAGE, INSTALLABLE_PLATFORM_SPECS
|
||||
|
||||
DEFAULT_PACKAGE_RESOURCE_BUTTON_TEXT = "PyPI"
|
||||
DEFAULT_PACKAGE_SOURCE_BUTTON_TEXT = "Source"
|
||||
|
||||
|
||||
def package_button_labels(product):
|
||||
resource = (getattr(product, "package_resource_button_text", "") or "").strip()
|
||||
source = (getattr(product, "package_source_button_text", "") or "").strip()
|
||||
return {
|
||||
"package_resource_button_text": resource or DEFAULT_PACKAGE_RESOURCE_BUTTON_TEXT,
|
||||
"package_source_button_text": source or DEFAULT_PACKAGE_SOURCE_BUTTON_TEXT,
|
||||
}
|
||||
|
||||
|
||||
def downloads_section_link(product):
|
||||
url = (getattr(product, "downloads_section_link_url", "") or "").strip()
|
||||
text = (getattr(product, "downloads_section_link_text", "") or "").strip()
|
||||
if url and text:
|
||||
return {"url": url, "text": text}
|
||||
return None
|
||||
|
||||
|
||||
def featured_version(qs):
|
||||
ordered = qs.order_by("order", "pk")
|
||||
cand = ordered.filter(is_featured=True).first()
|
||||
return cand if cand else ordered.first()
|
||||
|
||||
|
||||
def version_has_public_assets(version):
|
||||
return version.has_any_install_asset() or bool((version.package_resource_url or "").strip())
|
||||
|
||||
|
||||
def install_specs_from_versions(version_list):
|
||||
present = set()
|
||||
for ver in version_list:
|
||||
for field_name, *_ in INSTALLABLE_PLATFORM_SPECS:
|
||||
if ver.has_platform_asset(field_name):
|
||||
present.add(field_name)
|
||||
return tuple(s for s in INSTALLABLE_PLATFORM_SPECS if s[0] in present)
|
||||
|
||||
|
||||
def build_installable_channel_block(version):
|
||||
if not version_has_public_assets(version):
|
||||
return None
|
||||
block = {
|
||||
"version": version,
|
||||
"install_cells": [],
|
||||
"is_previous": version.is_previous_channel,
|
||||
}
|
||||
if version.has_any_install_asset():
|
||||
specs = install_specs_from_versions([version])
|
||||
block["install_cells"] = version.install_urls_for_specs(specs)
|
||||
return block
|
||||
|
||||
|
||||
def build_release_context(distribution, active_versions, product):
|
||||
context = {
|
||||
"distribution_installable": distribution == DISTRIBUTION_INSTALLABLE,
|
||||
"distribution_package": distribution == DISTRIBUTION_PACKAGE,
|
||||
"show_releases_section": False,
|
||||
"featured_version": None,
|
||||
"featured_channel": None,
|
||||
"featured_install_cells": [],
|
||||
"inline_download_channels": [],
|
||||
"show_older_versions_link": False,
|
||||
"package_versions": [],
|
||||
**package_button_labels(product),
|
||||
"downloads_section_link": downloads_section_link(product),
|
||||
}
|
||||
|
||||
if distribution == DISTRIBUTION_INSTALLABLE:
|
||||
featured = featured_version(active_versions)
|
||||
if featured and not version_has_public_assets(featured):
|
||||
for cand in active_versions.exclude(pk=featured.pk).order_by("order", "pk"):
|
||||
if version_has_public_assets(cand):
|
||||
featured = cand
|
||||
break
|
||||
context["featured_version"] = featured
|
||||
if featured and version_has_public_assets(featured):
|
||||
featured_block = build_installable_channel_block(featured)
|
||||
if featured_block:
|
||||
context["featured_channel"] = featured_block
|
||||
context["featured_install_cells"] = featured_block["install_cells"]
|
||||
context["show_releases_section"] = True
|
||||
|
||||
inline_blocks = []
|
||||
for ver in active_versions.order_by("order", "pk"):
|
||||
if featured and ver.pk == featured.pk:
|
||||
continue
|
||||
if not ver.show_on_product_page:
|
||||
continue
|
||||
block = build_installable_channel_block(ver)
|
||||
if block:
|
||||
inline_blocks.append(block)
|
||||
context["inline_download_channels"] = inline_blocks
|
||||
|
||||
shown_pks = {featured.pk} if featured else set()
|
||||
shown_pks.update(block["version"].pk for block in inline_blocks)
|
||||
older_qs = active_versions.order_by("order", "pk")
|
||||
if shown_pks:
|
||||
older_qs = older_qs.exclude(pk__in=shown_pks)
|
||||
context["show_older_versions_link"] = older_qs.exists()
|
||||
|
||||
elif distribution == DISTRIBUTION_PACKAGE:
|
||||
pkg_versions = list(active_versions.order_by("order", "pk"))
|
||||
context["package_versions"] = pkg_versions
|
||||
context["show_releases_section"] = len(pkg_versions) > 0
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def build_archive_context(distribution, active_versions, product):
|
||||
featured = featured_version(active_versions)
|
||||
inline_pks = set(
|
||||
active_versions.filter(show_on_product_page=True).values_list("pk", flat=True)
|
||||
)
|
||||
exclude_pks = set()
|
||||
if featured:
|
||||
exclude_pks.add(featured.pk)
|
||||
exclude_pks.update(inline_pks)
|
||||
archive = list(
|
||||
active_versions.exclude(pk__in=exclude_pks).order_by("order", "pk")
|
||||
if exclude_pks
|
||||
else active_versions.order_by("order", "pk")
|
||||
)
|
||||
context = {
|
||||
"featured_version": featured,
|
||||
"archive_versions": archive,
|
||||
**package_button_labels(product),
|
||||
}
|
||||
|
||||
if distribution == DISTRIBUTION_INSTALLABLE:
|
||||
specs = install_specs_from_versions(archive)
|
||||
context["archive_specs"] = specs
|
||||
archive_rows_installable = []
|
||||
for ver in archive:
|
||||
cells = []
|
||||
for field_name, label, icon in specs:
|
||||
cells.append(
|
||||
{
|
||||
"field": field_name,
|
||||
"label": label,
|
||||
"icon": icon,
|
||||
"url": ver.resolve_asset_url(field_name),
|
||||
"external": bool((getattr(ver, field_name) or "").strip()),
|
||||
}
|
||||
)
|
||||
archive_rows_installable.append({"version_obj": ver, "cells": cells})
|
||||
context["archive_rows_installable"] = archive_rows_installable
|
||||
context["archive_rows_package"] = False
|
||||
context["distribution_installable"] = True
|
||||
context["distribution_package"] = False
|
||||
else:
|
||||
context["archive_specs"] = ()
|
||||
context["archive_rows_installable"] = []
|
||||
context["archive_rows_package"] = True
|
||||
context["distribution_installable"] = False
|
||||
context["distribution_package"] = True
|
||||
|
||||
return context
|
||||
@@ -0,0 +1,37 @@
|
||||
from django.db.models.signals import post_delete, pre_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .models import SubProductVersion
|
||||
from .release_assets import RELEASE_FILE_BY_URL_FIELD
|
||||
|
||||
|
||||
def _stored_release_file_name(version, file_field):
|
||||
release_file = getattr(version, file_field)
|
||||
return release_file.name if release_file else ""
|
||||
|
||||
|
||||
def _delete_release_file_from_disk(version, file_field):
|
||||
release_file = getattr(version, file_field)
|
||||
if release_file:
|
||||
release_file.delete(save=False)
|
||||
|
||||
|
||||
@receiver(pre_save, sender=SubProductVersion)
|
||||
def delete_replaced_or_cleared_release_files(sender, instance, **kwargs):
|
||||
if not instance.pk:
|
||||
return
|
||||
try:
|
||||
previous = SubProductVersion.objects.get(pk=instance.pk)
|
||||
except SubProductVersion.DoesNotExist:
|
||||
return
|
||||
for file_field in RELEASE_FILE_BY_URL_FIELD.values():
|
||||
old_name = _stored_release_file_name(previous, file_field)
|
||||
new_name = _stored_release_file_name(instance, file_field)
|
||||
if old_name and old_name != new_name:
|
||||
_delete_release_file_from_disk(previous, file_field)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=SubProductVersion)
|
||||
def delete_release_files_on_version_delete(sender, instance, **kwargs):
|
||||
for file_field in RELEASE_FILE_BY_URL_FIELD.values():
|
||||
_delete_release_file_from_disk(instance, file_field)
|
||||
@@ -0,0 +1,77 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct
|
||||
|
||||
|
||||
class AdminAccessTest(TestCase):
|
||||
def setUp(self):
|
||||
self.superuser = User.objects.create_superuser(
|
||||
username="admin",
|
||||
email="admin@example.com",
|
||||
password="securepassword123",
|
||||
)
|
||||
self.client.login(username="admin", password="securepassword123")
|
||||
self.main_product = MainProduct.objects.create(
|
||||
name="Test Product",
|
||||
short_description="Short",
|
||||
description="Description",
|
||||
)
|
||||
self.sub_product = SubProduct.objects.create(
|
||||
main_product=self.main_product,
|
||||
name="Sub Product",
|
||||
short_description="Short sub",
|
||||
description="Sub description",
|
||||
)
|
||||
self.article = Article.objects.create(
|
||||
sub_product=self.sub_product,
|
||||
title="Test Article",
|
||||
description="Article body",
|
||||
)
|
||||
self.section = ArticleSection.objects.create(
|
||||
article=self.article,
|
||||
title="Link",
|
||||
value="https://example.com",
|
||||
)
|
||||
|
||||
def test_main_product_changelist_accessible(self):
|
||||
url = reverse("admin:products_mainproduct_changelist")
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_sub_product_changelist_accessible(self):
|
||||
url = reverse("admin:products_subproduct_changelist")
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_article_changelist_accessible(self):
|
||||
url = reverse("admin:products_article_changelist")
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_article_section_changelist_accessible(self):
|
||||
url = reverse("admin:products_articlesection_changelist")
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_main_product_change_accessible(self):
|
||||
url = reverse("admin:products_mainproduct_change", args=[self.main_product.pk])
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_sub_product_change_accessible(self):
|
||||
url = reverse("admin:products_subproduct_change", args=[self.sub_product.pk])
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_article_change_accessible(self):
|
||||
url = reverse("admin:products_article_change", args=[self.article.pk])
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_admin_requires_authentication(self):
|
||||
self.client.logout()
|
||||
url = reverse("admin:products_mainproduct_changelist")
|
||||
response = self.client.get(url)
|
||||
self.assertNotEqual(response.status_code, 200)
|
||||
@@ -0,0 +1,258 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from apps.products.models import (
|
||||
Article,
|
||||
ArticleSection,
|
||||
MainProduct,
|
||||
SubProduct,
|
||||
SubProductVersion,
|
||||
)
|
||||
|
||||
|
||||
class MainProductModelTest(TestCase):
|
||||
def setUp(self):
|
||||
self.product = MainProduct.objects.create(
|
||||
name="Test Product",
|
||||
short_description="Short description",
|
||||
description="Full description of the test product.",
|
||||
)
|
||||
|
||||
def test_str_representation(self):
|
||||
self.assertEqual(str(self.product), "Test Product")
|
||||
|
||||
def test_slug_auto_generated_on_create(self):
|
||||
self.assertEqual(self.product.slug, "test-product")
|
||||
|
||||
def test_slug_not_overwritten_on_update(self):
|
||||
self.product.name = "Changed Name"
|
||||
self.product.save()
|
||||
self.assertEqual(self.product.slug, "test-product")
|
||||
|
||||
def test_default_order_is_zero(self):
|
||||
self.assertEqual(self.product.order, 0)
|
||||
|
||||
def test_default_is_active_is_true(self):
|
||||
self.assertTrue(self.product.is_active)
|
||||
|
||||
def test_get_absolute_url(self):
|
||||
url = self.product.get_absolute_url()
|
||||
self.assertEqual(url, reverse("products:main_product_detail", kwargs={"main_slug": "test-product"}))
|
||||
|
||||
def test_slug_uniqueness(self):
|
||||
from django.db import IntegrityError
|
||||
with self.assertRaises(IntegrityError):
|
||||
MainProduct.objects.create(
|
||||
name="Test Product",
|
||||
slug="test-product",
|
||||
short_description="Another",
|
||||
description="Another",
|
||||
)
|
||||
|
||||
def test_timestamps_set_on_create(self):
|
||||
self.assertIsNotNone(self.product.created_at)
|
||||
self.assertIsNotNone(self.product.updated_at)
|
||||
|
||||
|
||||
class SubProductModelTest(TestCase):
|
||||
def setUp(self):
|
||||
self.main_product = MainProduct.objects.create(
|
||||
name="Main Product",
|
||||
short_description="Short",
|
||||
description="Description",
|
||||
)
|
||||
self.sub_product = SubProduct.objects.create(
|
||||
main_product=self.main_product,
|
||||
name="Sub Product",
|
||||
short_description="Sub short",
|
||||
description="Sub description.",
|
||||
)
|
||||
|
||||
def test_str_representation(self):
|
||||
self.assertIn("Main Product", str(self.sub_product))
|
||||
self.assertIn("Sub Product", str(self.sub_product))
|
||||
|
||||
def test_slug_auto_generated(self):
|
||||
self.assertEqual(self.sub_product.slug, "sub-product")
|
||||
|
||||
def test_get_absolute_url(self):
|
||||
url = self.sub_product.get_absolute_url()
|
||||
expected = reverse(
|
||||
"products:sub_product_detail",
|
||||
kwargs={"main_slug": "main-product", "sub_slug": "sub-product"},
|
||||
)
|
||||
self.assertEqual(url, expected)
|
||||
|
||||
def test_cascade_delete_with_main_product(self):
|
||||
main_pk = self.main_product.pk
|
||||
sub_pk = self.sub_product.pk
|
||||
self.main_product.delete()
|
||||
self.assertFalse(SubProduct.objects.filter(pk=sub_pk).exists())
|
||||
self.assertFalse(MainProduct.objects.filter(pk=main_pk).exists())
|
||||
|
||||
|
||||
class ArticleModelTest(TestCase):
|
||||
def setUp(self):
|
||||
self.main_product = MainProduct.objects.create(
|
||||
name="Main",
|
||||
short_description="Short",
|
||||
description="Desc",
|
||||
)
|
||||
self.sub_product = SubProduct.objects.create(
|
||||
main_product=self.main_product,
|
||||
name="Sub",
|
||||
short_description="Short",
|
||||
description="Desc",
|
||||
)
|
||||
self.article = Article.objects.create(
|
||||
sub_product=self.sub_product,
|
||||
title="Test Article",
|
||||
description="Article body.",
|
||||
)
|
||||
|
||||
def test_str_representation(self):
|
||||
self.assertEqual(str(self.article), "Test Article")
|
||||
|
||||
def test_cascade_delete_with_sub_product(self):
|
||||
article_pk = self.article.pk
|
||||
self.sub_product.delete()
|
||||
self.assertFalse(Article.objects.filter(pk=article_pk).exists())
|
||||
|
||||
def test_main_product_article(self):
|
||||
article = Article.objects.create(
|
||||
main_product=self.main_product,
|
||||
title="Product Article",
|
||||
description="Body.",
|
||||
)
|
||||
self.assertEqual(article.main_product, self.main_product)
|
||||
self.assertIsNone(article.sub_product_id)
|
||||
|
||||
def test_cascade_delete_with_main_product(self):
|
||||
article = Article.objects.create(
|
||||
main_product=self.main_product,
|
||||
title="Product Article",
|
||||
description="Body.",
|
||||
)
|
||||
article_pk = article.pk
|
||||
self.main_product.delete()
|
||||
self.assertFalse(Article.objects.filter(pk=article_pk).exists())
|
||||
|
||||
def test_requires_exactly_one_parent(self):
|
||||
article = Article(
|
||||
main_product=self.main_product,
|
||||
sub_product=self.sub_product,
|
||||
title="Invalid",
|
||||
description="Body.",
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
article.full_clean()
|
||||
|
||||
def test_requires_a_parent(self):
|
||||
article = Article(title="Orphan", description="Body.")
|
||||
with self.assertRaises(ValidationError):
|
||||
article.full_clean()
|
||||
|
||||
|
||||
class ReleaseFileStorageTest(TestCase):
|
||||
def test_save_overwrites_existing_release_file(self):
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
|
||||
from apps.products.models import MainProduct, SubProductVersion
|
||||
|
||||
main = MainProduct.objects.create(
|
||||
name="Storage Test",
|
||||
short_description="s",
|
||||
description="d",
|
||||
)
|
||||
version = SubProductVersion.objects.create(
|
||||
main_product=main,
|
||||
version="1.0",
|
||||
windows_download_file=SimpleUploadedFile("setup.exe", b"v1"),
|
||||
is_active=True,
|
||||
)
|
||||
version.windows_download_file.save(
|
||||
"setup.exe",
|
||||
SimpleUploadedFile("setup.exe", b"v2"),
|
||||
save=True,
|
||||
)
|
||||
version.refresh_from_db()
|
||||
with version.windows_download_file.open("rb") as handle:
|
||||
self.assertEqual(handle.read(), b"v2")
|
||||
|
||||
|
||||
class SubProductVersionModelTest(TestCase):
|
||||
def setUp(self):
|
||||
self.main_product = MainProduct.objects.create(
|
||||
name="Main",
|
||||
short_description="Short",
|
||||
description="Desc",
|
||||
)
|
||||
self.sub_product = SubProduct.objects.create(
|
||||
main_product=self.main_product,
|
||||
name="Sub",
|
||||
short_description="Short",
|
||||
description="Desc",
|
||||
)
|
||||
|
||||
def test_main_product_version(self):
|
||||
version = SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="1.0.0",
|
||||
)
|
||||
self.assertEqual(version.main_product, self.main_product)
|
||||
self.assertIsNone(version.sub_product_id)
|
||||
|
||||
def test_requires_exactly_one_parent(self):
|
||||
version = SubProductVersion(
|
||||
main_product=self.main_product,
|
||||
sub_product=self.sub_product,
|
||||
version="1.0.0",
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
version.full_clean()
|
||||
|
||||
def test_display_channel_label_uses_custom_text(self):
|
||||
from apps.products.models import RELEASE_CHANNEL_BETA
|
||||
|
||||
version = SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="2.0",
|
||||
release_channel=RELEASE_CHANNEL_BETA,
|
||||
channel_label="Preview build",
|
||||
)
|
||||
self.assertEqual(version.display_channel_label, "Preview build")
|
||||
self.assertEqual(version.display_channel_css_modifier, "custom")
|
||||
|
||||
def test_display_channel_label_uses_preset(self):
|
||||
from apps.products.models import RELEASE_CHANNEL_PREVIOUS
|
||||
|
||||
version = SubProductVersion.objects.create(
|
||||
sub_product=self.sub_product,
|
||||
version="1.0",
|
||||
release_channel=RELEASE_CHANNEL_PREVIOUS,
|
||||
)
|
||||
self.assertEqual(version.display_channel_label, "Previous")
|
||||
self.assertTrue(version.is_previous_channel)
|
||||
|
||||
|
||||
class ArticleSectionModelTest(TestCase):
|
||||
def setUp(self):
|
||||
main = MainProduct.objects.create(name="M", short_description="s", description="d")
|
||||
sub = SubProduct.objects.create(main_product=main, name="S", short_description="s", description="d")
|
||||
self.article = Article.objects.create(sub_product=sub, title="Article", description="Desc")
|
||||
self.section = ArticleSection.objects.create(
|
||||
article=self.article,
|
||||
title="Link",
|
||||
value="https://example.com",
|
||||
)
|
||||
|
||||
def test_str_representation(self):
|
||||
self.assertIn("Article", str(self.section))
|
||||
self.assertIn("Link", str(self.section))
|
||||
|
||||
def test_cascade_delete_with_article(self):
|
||||
section_pk = self.section.pk
|
||||
self.article.delete()
|
||||
self.assertFalse(ArticleSection.objects.filter(pk=section_pk).exists())
|
||||
@@ -0,0 +1,350 @@
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct, SubProductVersion
|
||||
|
||||
|
||||
class ProductViewsSetup(TestCase):
|
||||
def setUp(self):
|
||||
self.main_product = MainProduct.objects.create(
|
||||
name="Tecvico",
|
||||
slug="tecvico",
|
||||
short_description="Radiomics software",
|
||||
description="Full description.",
|
||||
)
|
||||
self.sub_product = SubProduct.objects.create(
|
||||
main_product=self.main_product,
|
||||
name="Image Processing",
|
||||
slug="image-processing",
|
||||
short_description="Filtering and registration",
|
||||
description="Sub description.",
|
||||
)
|
||||
self.article = Article.objects.create(
|
||||
sub_product=self.sub_product,
|
||||
title="Filtering",
|
||||
description="Article body.",
|
||||
)
|
||||
ArticleSection.objects.create(
|
||||
article=self.article,
|
||||
title="Standard",
|
||||
value="IBSI 2.0",
|
||||
)
|
||||
|
||||
|
||||
class ProductOverviewViewTest(ProductViewsSetup):
|
||||
def test_overview_returns_200(self):
|
||||
response = self.client.get(reverse("products:overview"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_overview_uses_correct_template(self):
|
||||
response = self.client.get(reverse("products:overview"))
|
||||
self.assertTemplateUsed(response, "products/overview.html")
|
||||
|
||||
def test_overview_contains_main_product(self):
|
||||
response = self.client.get(reverse("products:overview"))
|
||||
self.assertIn(self.main_product, response.context["main_products"])
|
||||
|
||||
def test_inactive_product_excluded(self):
|
||||
inactive = MainProduct.objects.create(
|
||||
name="Inactive",
|
||||
short_description="s",
|
||||
description="d",
|
||||
is_active=False,
|
||||
)
|
||||
response = self.client.get(reverse("products:overview"))
|
||||
self.assertNotIn(inactive, response.context["main_products"])
|
||||
|
||||
|
||||
class MainProductDetailViewTest(ProductViewsSetup):
|
||||
def test_detail_returns_200(self):
|
||||
url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_detail_uses_correct_template(self):
|
||||
url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"})
|
||||
response = self.client.get(url)
|
||||
self.assertTemplateUsed(response, "products/main_detail.html")
|
||||
|
||||
def test_detail_contains_product_in_context(self):
|
||||
url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.context["main_product"], self.main_product)
|
||||
|
||||
def test_nonexistent_slug_returns_404(self):
|
||||
url = reverse("products:main_product_detail", kwargs={"main_slug": "nonexistent"})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_inactive_product_returns_404(self):
|
||||
self.main_product.is_active = False
|
||||
self.main_product.save()
|
||||
url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_detail_articles_in_context(self):
|
||||
main_article = Article.objects.create(
|
||||
main_product=self.main_product,
|
||||
title="Overview",
|
||||
description="Main product article.",
|
||||
)
|
||||
url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"})
|
||||
response = self.client.get(url)
|
||||
self.assertIn("articles", response.context)
|
||||
self.assertIn(main_article, list(response.context["articles"]))
|
||||
self.assertNotIn(self.article, list(response.context["articles"]))
|
||||
|
||||
|
||||
class SubProductDetailViewTest(ProductViewsSetup):
|
||||
def test_sub_detail_returns_200(self):
|
||||
url = reverse(
|
||||
"products:sub_product_detail",
|
||||
kwargs={"main_slug": "tecvico", "sub_slug": "image-processing"},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_sub_detail_uses_correct_template(self):
|
||||
url = reverse(
|
||||
"products:sub_product_detail",
|
||||
kwargs={"main_slug": "tecvico", "sub_slug": "image-processing"},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertTemplateUsed(response, "products/sub_detail.html")
|
||||
|
||||
def test_sub_detail_context_keys(self):
|
||||
url = reverse(
|
||||
"products:sub_product_detail",
|
||||
kwargs={"main_slug": "tecvico", "sub_slug": "image-processing"},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertIn("main_product", response.context)
|
||||
self.assertIn("sub_product", response.context)
|
||||
self.assertIn("articles", response.context)
|
||||
self.assertIn("siblings", response.context)
|
||||
|
||||
def test_sub_detail_articles_in_context(self):
|
||||
url = reverse(
|
||||
"products:sub_product_detail",
|
||||
kwargs={"main_slug": "tecvico", "sub_slug": "image-processing"},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertIn(self.article, list(response.context["articles"]))
|
||||
|
||||
def test_nonexistent_sub_slug_returns_404(self):
|
||||
url = reverse(
|
||||
"products:sub_product_detail",
|
||||
kwargs={"main_slug": "tecvico", "sub_slug": "does-not-exist"},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_mismatched_main_slug_returns_404(self):
|
||||
url = reverse(
|
||||
"products:sub_product_detail",
|
||||
kwargs={"main_slug": "wrong-product", "sub_slug": "image-processing"},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class SubProductOlderVersionsViewTest(ProductViewsSetup):
|
||||
def test_versions_returns_200(self):
|
||||
from apps.products.models import SubProductVersion
|
||||
|
||||
SubProductVersion.objects.create(
|
||||
sub_product=self.sub_product,
|
||||
version="9.9",
|
||||
is_featured=True,
|
||||
release_channel="stable",
|
||||
windows_download_url="https://example.com/w",
|
||||
is_active=True,
|
||||
)
|
||||
SubProductVersion.objects.create(
|
||||
sub_product=self.sub_product,
|
||||
version="9.8",
|
||||
is_featured=False,
|
||||
windows_download_url="https://example.com/w2",
|
||||
is_active=True,
|
||||
)
|
||||
url = reverse(
|
||||
"products:sub_product_versions",
|
||||
kwargs={"main_slug": "tecvico", "sub_slug": "image-processing"},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "products/versions_archive.html")
|
||||
|
||||
def test_main_versions_returns_200(self):
|
||||
from apps.products.models import SubProductVersion
|
||||
|
||||
SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="9.9",
|
||||
is_featured=True,
|
||||
release_channel="stable",
|
||||
windows_download_url="https://example.com/win.exe",
|
||||
is_active=True,
|
||||
)
|
||||
SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="9.8",
|
||||
is_active=True,
|
||||
)
|
||||
url = reverse(
|
||||
"products:main_product_versions",
|
||||
kwargs={"main_slug": "tecvico"},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "products/versions_archive.html")
|
||||
|
||||
def test_main_detail_releases_in_context(self):
|
||||
from apps.products.models import SubProductVersion
|
||||
|
||||
SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="1.0",
|
||||
windows_download_url="https://example.com/win.exe",
|
||||
is_active=True,
|
||||
)
|
||||
url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"})
|
||||
response = self.client.get(url)
|
||||
self.assertTrue(response.context["show_releases_section"])
|
||||
|
||||
def test_installable_shows_release_channel_badges(self):
|
||||
from apps.products.models import RELEASE_CHANNEL_BETA, RELEASE_CHANNEL_PREVIOUS, SubProductVersion
|
||||
|
||||
SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="2.0",
|
||||
release_channel=RELEASE_CHANNEL_BETA,
|
||||
is_featured=True,
|
||||
windows_download_url="https://example.com/beta.exe",
|
||||
is_active=True,
|
||||
)
|
||||
SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="1.9",
|
||||
release_channel=RELEASE_CHANNEL_PREVIOUS,
|
||||
show_on_product_page=True,
|
||||
windows_download_url="https://example.com/prev.exe",
|
||||
is_active=True,
|
||||
)
|
||||
url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"})
|
||||
response = self.client.get(url)
|
||||
self.assertContains(response, "Beta")
|
||||
self.assertContains(response, "Previous")
|
||||
self.assertEqual(len(response.context["inline_download_channels"]), 1)
|
||||
|
||||
def test_custom_channel_label_overrides_preset(self):
|
||||
from apps.products.models import RELEASE_CHANNEL_STABLE, SubProductVersion
|
||||
|
||||
SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="3.0",
|
||||
release_channel=RELEASE_CHANNEL_STABLE,
|
||||
channel_label="Early Access",
|
||||
is_featured=True,
|
||||
windows_download_url="https://example.com/ea.exe",
|
||||
is_active=True,
|
||||
)
|
||||
url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"})
|
||||
response = self.client.get(url)
|
||||
self.assertContains(response, "Early Access")
|
||||
self.assertNotContains(response, ">Stable<")
|
||||
|
||||
|
||||
@override_settings(MEDIA_ROOT=settings.BASE_DIR / "test_media_releases")
|
||||
class ReleaseAssetDownloadViewTest(ProductViewsSetup):
|
||||
def test_uploaded_file_download(self):
|
||||
release_file = SimpleUploadedFile("setup.exe", b"binary-payload")
|
||||
version = SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="2.0",
|
||||
windows_download_file=release_file,
|
||||
is_active=True,
|
||||
)
|
||||
url = reverse(
|
||||
"products:release_asset_download",
|
||||
kwargs={"version_id": version.pk, "asset": "windows"},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(b"".join(response.streaming_content), b"binary-payload")
|
||||
self.assertIn("attachment", response["Content-Disposition"])
|
||||
self.assertIn("setup.exe", response["Content-Disposition"])
|
||||
|
||||
def test_external_url_blocks_file_download(self):
|
||||
version = SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="2.1",
|
||||
windows_download_url="https://example.com/win.exe",
|
||||
windows_download_file=SimpleUploadedFile("local.exe", b"x"),
|
||||
is_active=True,
|
||||
)
|
||||
url = reverse(
|
||||
"products:release_asset_download",
|
||||
kwargs={"version_id": version.pk, "asset": "windows"},
|
||||
)
|
||||
self.assertEqual(self.client.get(url).status_code, 404)
|
||||
|
||||
def test_product_page_uses_file_when_no_url(self):
|
||||
version = SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="3.0",
|
||||
windows_download_file=SimpleUploadedFile("win.tar.gz", b"gz"),
|
||||
is_active=True,
|
||||
)
|
||||
detail_url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"})
|
||||
response = self.client.get(detail_url)
|
||||
download_url = reverse(
|
||||
"products:release_asset_download",
|
||||
kwargs={"version_id": version.pk, "asset": "windows"},
|
||||
)
|
||||
self.assertContains(response, download_url)
|
||||
|
||||
def test_inactive_version_returns_404(self):
|
||||
version = SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="0.1",
|
||||
windows_download_file=SimpleUploadedFile("a.exe", b"a"),
|
||||
is_active=False,
|
||||
)
|
||||
url = reverse(
|
||||
"products:release_asset_download",
|
||||
kwargs={"version_id": version.pk, "asset": "windows"},
|
||||
)
|
||||
self.assertEqual(self.client.get(url).status_code, 404)
|
||||
|
||||
def test_clearing_file_removes_from_disk(self):
|
||||
version = SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="4.0",
|
||||
windows_download_file=SimpleUploadedFile("remove-me.exe", b"data"),
|
||||
is_active=True,
|
||||
)
|
||||
stored_path = version.windows_download_file.path
|
||||
self.assertTrue(stored_path)
|
||||
version.windows_download_file = ""
|
||||
version.save()
|
||||
version.refresh_from_db()
|
||||
self.assertFalse(version.windows_download_file)
|
||||
self.assertEqual(version.windows_download_filename, "")
|
||||
self.assertFalse(os.path.exists(stored_path))
|
||||
|
||||
def test_deleting_version_removes_files_from_disk(self):
|
||||
version = SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="5.0",
|
||||
windows_download_file=SimpleUploadedFile("gone.exe", b"data"),
|
||||
is_active=True,
|
||||
)
|
||||
stored_path = version.windows_download_file.path
|
||||
version.delete()
|
||||
self.assertFalse(os.path.exists(stored_path))
|
||||
@@ -0,0 +1,34 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "products"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.ProductOverviewView.as_view(), name="overview"),
|
||||
path(
|
||||
"releases/<int:version_id>/<slug:asset>/download/",
|
||||
views.ReleaseAssetDownloadView.as_view(),
|
||||
name="release_asset_download",
|
||||
),
|
||||
path(
|
||||
"<slug:main_slug>/",
|
||||
views.MainProductDetailView.as_view(),
|
||||
name="main_product_detail",
|
||||
),
|
||||
path(
|
||||
"<slug:main_slug>/versions/",
|
||||
views.MainProductOlderVersionsView.as_view(),
|
||||
name="main_product_versions",
|
||||
),
|
||||
path(
|
||||
"<slug:main_slug>/<slug:sub_slug>/versions/",
|
||||
views.SubProductOlderVersionsView.as_view(),
|
||||
name="sub_product_versions",
|
||||
),
|
||||
path(
|
||||
"<slug:main_slug>/<slug:sub_slug>/",
|
||||
views.SubProductDetailView.as_view(),
|
||||
name="sub_product_detail",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,155 @@
|
||||
import mimetypes
|
||||
|
||||
from django.http import FileResponse, Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views import View
|
||||
from django.views.generic import DetailView, ListView, TemplateView
|
||||
|
||||
from .models import Article, ArticleCitation, ArticleSection, MainProduct, ProductVideo, SubProduct, SubProductVersion
|
||||
from .release_assets import FILE_FIELD_BY_ASSET_KEY, RELEASE_ASSET_KEYS, URL_FIELD_BY_ASSET_KEY
|
||||
from .release_context import build_archive_context, build_release_context
|
||||
|
||||
|
||||
class ReleaseAssetDownloadView(View):
|
||||
def get(self, request, version_id, asset):
|
||||
if asset not in RELEASE_ASSET_KEYS:
|
||||
raise Http404
|
||||
version = get_object_or_404(SubProductVersion, pk=version_id, is_active=True)
|
||||
parent = version.sub_product or version.main_product
|
||||
if parent is None or not parent.is_active:
|
||||
raise Http404
|
||||
url_field = URL_FIELD_BY_ASSET_KEY[asset]
|
||||
if (getattr(version, url_field) or "").strip():
|
||||
raise Http404
|
||||
file_field = FILE_FIELD_BY_ASSET_KEY[asset]
|
||||
release_file = getattr(version, file_field)
|
||||
if not release_file:
|
||||
raise Http404
|
||||
filename = version.release_download_filename(asset)
|
||||
content_type, _ = mimetypes.guess_type(filename)
|
||||
return FileResponse(
|
||||
release_file.open("rb"),
|
||||
as_attachment=True,
|
||||
filename=filename,
|
||||
content_type=content_type or "application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
class ProductOverviewView(ListView):
|
||||
model = MainProduct
|
||||
template_name = "products/overview.html"
|
||||
context_object_name = "main_products"
|
||||
queryset = MainProduct.objects.filter(is_active=True).prefetch_related(
|
||||
"sub_products"
|
||||
)
|
||||
|
||||
|
||||
class MainProductDetailView(DetailView):
|
||||
model = MainProduct
|
||||
template_name = "products/main_detail.html"
|
||||
context_object_name = "main_product"
|
||||
slug_url_kwarg = "main_slug"
|
||||
queryset = MainProduct.objects.filter(is_active=True).prefetch_related(
|
||||
"sub_products"
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["articles"] = self.object.articles.prefetch_related(
|
||||
"sections", "citations"
|
||||
).all()
|
||||
context["product_videos"] = self.object.videos.filter(is_active=True).order_by("order")
|
||||
release_context = build_release_context(
|
||||
self.object.distribution,
|
||||
self.object.versions.filter(is_active=True),
|
||||
self.object,
|
||||
)
|
||||
release_context["versions_archive_url"] = self.object.get_versions_archive_url()
|
||||
context.update(release_context)
|
||||
return context
|
||||
|
||||
|
||||
class MainProductOlderVersionsView(TemplateView):
|
||||
template_name = "products/versions_archive.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
main_product = get_object_or_404(
|
||||
MainProduct,
|
||||
slug=self.kwargs["main_slug"],
|
||||
is_active=True,
|
||||
)
|
||||
active_versions = main_product.versions.filter(is_active=True)
|
||||
context["main_product"] = main_product
|
||||
context["sub_product"] = None
|
||||
context["product_detail_url"] = main_product.get_absolute_url()
|
||||
context.update(
|
||||
build_archive_context(
|
||||
main_product.distribution, active_versions, main_product
|
||||
)
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class SubProductDetailView(TemplateView):
|
||||
template_name = "products/sub_detail.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
main_product = get_object_or_404(
|
||||
MainProduct,
|
||||
slug=self.kwargs["main_slug"],
|
||||
is_active=True,
|
||||
)
|
||||
sub_product = get_object_or_404(
|
||||
SubProduct,
|
||||
slug=self.kwargs["sub_slug"],
|
||||
main_product=main_product,
|
||||
is_active=True,
|
||||
)
|
||||
context["main_product"] = main_product
|
||||
context["sub_product"] = sub_product
|
||||
context["articles"] = sub_product.articles.prefetch_related(
|
||||
"sections", "citations"
|
||||
).all()
|
||||
context["product_videos"] = sub_product.videos.filter(is_active=True).order_by("order")
|
||||
context["siblings"] = (
|
||||
SubProduct.objects.filter(main_product=main_product, is_active=True)
|
||||
.exclude(pk=sub_product.pk)
|
||||
.order_by("order", "name")
|
||||
)
|
||||
active_versions = sub_product.versions.filter(is_active=True)
|
||||
release_context = build_release_context(
|
||||
sub_product.distribution, active_versions, sub_product
|
||||
)
|
||||
release_context["versions_archive_url"] = sub_product.get_versions_archive_url()
|
||||
context.update(release_context)
|
||||
return context
|
||||
|
||||
|
||||
class SubProductOlderVersionsView(TemplateView):
|
||||
template_name = "products/versions_archive.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
main_product = get_object_or_404(
|
||||
MainProduct,
|
||||
slug=self.kwargs["main_slug"],
|
||||
is_active=True,
|
||||
)
|
||||
sub_product = get_object_or_404(
|
||||
SubProduct,
|
||||
slug=self.kwargs["sub_slug"],
|
||||
main_product=main_product,
|
||||
is_active=True,
|
||||
)
|
||||
active_versions = sub_product.versions.filter(is_active=True)
|
||||
context["main_product"] = main_product
|
||||
context["sub_product"] = sub_product
|
||||
context["product_detail_url"] = sub_product.get_absolute_url()
|
||||
context.update(
|
||||
build_archive_context(
|
||||
sub_product.distribution, active_versions, sub_product
|
||||
)
|
||||
)
|
||||
return context
|
||||
Reference in New Issue
Block a user