feat: rebuild site content management
Replace legacy FAQ, downloads, and release flows with project and blog publishing. Add configurable branding and theme fields plus clean-start Compose seeding support.
This commit is contained in:
+5
-122
@@ -3,7 +3,7 @@ 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
|
||||
from .models import Article, ArticleCitation, ArticleSection, MainProduct, ProductVideo, SubProduct
|
||||
|
||||
|
||||
class ArticleCitationInline(admin.TabularInline):
|
||||
@@ -38,41 +38,6 @@ class MainProductArticleInline(admin.StackedInline):
|
||||
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"
|
||||
@@ -98,7 +63,7 @@ class ProductVideoInline(admin.StackedInline):
|
||||
class SubProductInline(admin.StackedInline):
|
||||
model = SubProduct
|
||||
extra = 0
|
||||
fields = ("name", "slug", "distribution", "short_description", "image", "logo", "show_on_homepage", "homepage_order", "order", "is_active")
|
||||
fields = ("name", "slug", "short_description", "image", "logo", "show_on_homepage", "homepage_order", "order", "is_active")
|
||||
ordering = ("order",)
|
||||
show_change_link = True
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
@@ -111,7 +76,7 @@ class MainProductAdmin(admin.ModelAdmin):
|
||||
search_fields = ("name", "description")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
|
||||
inlines = [MainProductArticleInline, ProductVideoInline, MainProductVersionInline, SubProductInline]
|
||||
inlines = [MainProductArticleInline, ProductVideoInline, SubProductInline]
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
@@ -120,11 +85,6 @@ class MainProductAdmin(admin.ModelAdmin):
|
||||
"name",
|
||||
"slug",
|
||||
"short_description",
|
||||
"distribution",
|
||||
"package_resource_button_text",
|
||||
"package_source_button_text",
|
||||
"downloads_section_link_text",
|
||||
"downloads_section_link_url",
|
||||
)
|
||||
},
|
||||
),
|
||||
@@ -162,19 +122,18 @@ 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")
|
||||
list_filter = ("is_active", "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]
|
||||
inlines = [SubProductArticleInline, SubProductVideoInline]
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
@@ -183,12 +142,7 @@ class SubProductAdmin(admin.ModelAdmin):
|
||||
"main_product",
|
||||
"name",
|
||||
"slug",
|
||||
"distribution",
|
||||
"short_description",
|
||||
"package_resource_button_text",
|
||||
"package_source_button_text",
|
||||
"downloads_section_link_text",
|
||||
"downloads_section_link_url",
|
||||
)
|
||||
},
|
||||
),
|
||||
@@ -261,74 +215,3 @@ class ArticleSectionAdmin(admin.ModelAdmin):
|
||||
("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 "—"
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.apps import AppConfig
|
||||
class ProductsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.products"
|
||||
verbose_name = "Products"
|
||||
verbose_name = "Projects"
|
||||
|
||||
def ready(self):
|
||||
from . import signals # noqa: F401
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
# Generated by Django 5.2.16 on 2026-09-23 22:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0021_release_channels'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='mainproduct',
|
||||
options={'ordering': ['order', 'name'], 'verbose_name': 'Main Project', 'verbose_name_plural': 'Main Projects'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='productvideo',
|
||||
options={'ordering': ['order', 'pk'], 'verbose_name': 'Project Video', 'verbose_name_plural': 'Project Videos'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='subproduct',
|
||||
options={'ordering': ['order', 'name'], 'verbose_name': 'Sub-project', 'verbose_name_plural': 'Sub-projects'},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='mainproduct',
|
||||
name='distribution',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='mainproduct',
|
||||
name='downloads_section_link_text',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='mainproduct',
|
||||
name='downloads_section_link_url',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='mainproduct',
|
||||
name='package_resource_button_text',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='mainproduct',
|
||||
name='package_source_button_text',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='subproduct',
|
||||
name='distribution',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='subproduct',
|
||||
name='downloads_section_link_text',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='subproduct',
|
||||
name='downloads_section_link_url',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='subproduct',
|
||||
name='package_resource_button_text',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='subproduct',
|
||||
name='package_source_button_text',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='mainproduct',
|
||||
name='homepage_order',
|
||||
field=models.PositiveIntegerField(default=0, help_text='Order within the homepage Projects section.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='mainproduct',
|
||||
name='show_on_homepage',
|
||||
field=models.BooleanField(default=False, help_text='Display this project in the homepage Projects section.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='productvideo',
|
||||
name='video_styled_background',
|
||||
field=models.BooleanField(default=False, help_text='Show the video in a glass panel with an ambient glow.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subproduct',
|
||||
name='homepage_order',
|
||||
field=models.PositiveIntegerField(default=0, help_text='Order within the homepage Projects section.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subproduct',
|
||||
name='show_on_homepage',
|
||||
field=models.BooleanField(default=False, help_text='Display this sub-project in the homepage Projects section.'),
|
||||
),
|
||||
]
|
||||
+12
-91
@@ -30,13 +30,6 @@ INSTALLABLE_PLATFORM_SPECS = (
|
||||
("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"
|
||||
@@ -66,7 +59,6 @@ RELEASE_CHANNEL_LABELS = {
|
||||
RELEASE_CHANNEL_PREVIOUS: "Previous",
|
||||
}
|
||||
|
||||
|
||||
class MainProduct(models.Model):
|
||||
name = models.CharField(max_length=200)
|
||||
slug = models.SlugField(unique=True, blank=True)
|
||||
@@ -76,42 +68,17 @@ class MainProduct(models.Model):
|
||||
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.")
|
||||
show_on_homepage = models.BooleanField(default=False, help_text="Display this project in the homepage Projects section.")
|
||||
homepage_order = models.PositiveIntegerField(default=0, help_text="Order within the homepage Projects 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"
|
||||
verbose_name = "Main Project"
|
||||
verbose_name_plural = "Main Projects"
|
||||
|
||||
@property
|
||||
def rendered_description(self):
|
||||
@@ -128,18 +95,7 @@ class MainProduct(models.Model):
|
||||
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,
|
||||
@@ -156,41 +112,16 @@ class SubProduct(models.Model):
|
||||
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.",
|
||||
)
|
||||
show_on_homepage = models.BooleanField(default=False, help_text="Display this sub-project in the homepage Projects section.")
|
||||
homepage_order = models.PositiveIntegerField(default=0, help_text="Order within the homepage Projects section.")
|
||||
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"
|
||||
verbose_name = "Sub-project"
|
||||
verbose_name_plural = "Sub-projects"
|
||||
|
||||
@property
|
||||
def rendered_description(self):
|
||||
@@ -213,16 +144,6 @@ class SubProduct(models.Model):
|
||||
},
|
||||
)
|
||||
|
||||
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,
|
||||
@@ -287,7 +208,7 @@ class Article(models.Model):
|
||||
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."
|
||||
"An article must belong to exactly one main project or sub-project."
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
@@ -572,8 +493,8 @@ class ProductVideo(VideoBlockMixin, models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ["order", "pk"]
|
||||
verbose_name = "Product Video"
|
||||
verbose_name_plural = "Product Videos"
|
||||
verbose_name = "Project Video"
|
||||
verbose_name_plural = "Project Videos"
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=(
|
||||
@@ -590,7 +511,7 @@ class ProductVideo(VideoBlockMixin, models.Model):
|
||||
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."
|
||||
"A project video must belong to exactly one main project or sub-project."
|
||||
)
|
||||
self.clean_video_fields(require=True)
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct, SubProductVersion
|
||||
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct
|
||||
|
||||
|
||||
class ProductViewsSetup(TestCase):
|
||||
@@ -153,198 +149,8 @@ class SubProductDetailViewTest(ProductViewsSetup):
|
||||
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": "com2care", "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": "com2care"},
|
||||
)
|
||||
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": "com2care"})
|
||||
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": "com2care"})
|
||||
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": "com2care"})
|
||||
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": "com2care"})
|
||||
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))
|
||||
class RemovedDistributionViewsTest(ProductViewsSetup):
|
||||
def test_project_pages_do_not_expose_downloads_or_versions(self):
|
||||
response = self.client.get(self.main_product.get_absolute_url())
|
||||
self.assertNotContains(response, "Downloads")
|
||||
self.assertNotContains(response, "Older releases")
|
||||
|
||||
@@ -6,11 +6,6 @@ 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>/videos/",
|
||||
views.ProductVideoArchiveView.as_view(),
|
||||
@@ -26,16 +21,6 @@ urlpatterns = [
|
||||
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(),
|
||||
|
||||
+2
-95
@@ -1,14 +1,9 @@
|
||||
import mimetypes
|
||||
|
||||
from django.http import FileResponse, Http404
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
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
|
||||
from .models import MainProduct, ProductVideo, SubProduct
|
||||
|
||||
VIDEO_PREVIEW_LIMIT = 3
|
||||
VIDEO_ARCHIVE_PAGE_SIZE = 6
|
||||
@@ -23,31 +18,6 @@ def _product_video_preview(queryset, archive_url):
|
||||
}
|
||||
|
||||
|
||||
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"
|
||||
@@ -81,35 +51,6 @@ class MainProductDetailView(DetailView):
|
||||
),
|
||||
)
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@@ -152,12 +93,6 @@ class SubProductDetailView(TemplateView):
|
||||
.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
|
||||
|
||||
|
||||
@@ -203,31 +138,3 @@ class ProductVideoArchiveView(ListView):
|
||||
}
|
||||
)
|
||||
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