From f0a9d5832b19af1d9adcbf027bf7512d3702676f Mon Sep 17 00:00:00 2001 From: mohamad Date: Sat, 16 May 2026 19:13:32 +0330 Subject: [PATCH] feat: change entire downloads --- apps/core/management/commands/seed_content.py | 2 +- apps/core/utils.py | 34 ++ apps/pages/admin.py | 3 +- .../migrations/0003_faqentry_answer_format.py | 18 + apps/pages/models.py | 9 + apps/pages/tests/test_views.py | 46 +- apps/pages/urls.py | 1 - apps/pages/views.py | 20 +- apps/products/admin.py | 97 +++- .../migrations/0002_subproductrelease.py | 34 ++ ...003_article_description_format_and_more.py | 33 ++ .../0004_distribution_and_versions.py | 145 ++++++ apps/products/models.py | 110 +++++ apps/products/tests/test_views.py | 27 ++ apps/products/urls.py | 5 + apps/products/views.py | 124 ++++- requirements.txt | 1 + static/css/main.css | 440 ++++++++++++++++++ templates/pages/downloads.html | 119 ----- templates/pages/faq.html | 2 +- templates/pages/home.html | 2 +- templates/partials/_footer.html | 1 - templates/partials/_navbar.html | 6 - templates/products/main_detail.html | 2 +- templates/products/sub_detail.html | 111 ++++- templates/products/sub_versions.html | 104 +++++ 26 files changed, 1283 insertions(+), 213 deletions(-) create mode 100644 apps/core/utils.py create mode 100644 apps/pages/migrations/0003_faqentry_answer_format.py create mode 100644 apps/products/migrations/0002_subproductrelease.py create mode 100644 apps/products/migrations/0003_article_description_format_and_more.py create mode 100644 apps/products/migrations/0004_distribution_and_versions.py delete mode 100644 templates/pages/downloads.html create mode 100644 templates/products/sub_versions.html diff --git a/apps/core/management/commands/seed_content.py b/apps/core/management/commands/seed_content.py index 26159cd..72f6d8a 100644 --- a/apps/core/management/commands/seed_content.py +++ b/apps/core/management/commands/seed_content.py @@ -226,7 +226,7 @@ FAQ_ENTRIES = [ "answer": ( "Radiuma currently fully supports Windows 10 and above (64-bit). " "New versions to support macOS and Linux systems are under active development " - "and coming soon. Follow our Discord or check the Downloads page for updates." + "and coming soon. Follow our Discord or check each product module page for updates." ), "order": 3, }, diff --git a/apps/core/utils.py b/apps/core/utils.py new file mode 100644 index 0000000..e237da2 --- /dev/null +++ b/apps/core/utils.py @@ -0,0 +1,34 @@ +import markdown as _md +from django.utils.html import escape, mark_safe + +FORMAT_PLAIN = "plain" +FORMAT_MARKDOWN = "markdown" +FORMAT_HTML = "html" + +CONTENT_FORMAT_CHOICES = [ + (FORMAT_PLAIN, "Plain Text"), + (FORMAT_MARKDOWN, "Markdown"), + (FORMAT_HTML, "HTML"), +] + +_MD_EXTENSIONS = ["extra", "nl2br", "sane_lists"] + + +def render_content(text: str, fmt: str) -> str: + if not text: + return mark_safe("") + + if fmt == FORMAT_HTML: + return mark_safe(text) + + if fmt == FORMAT_MARKDOWN: + return mark_safe(_md.markdown(text, extensions=_MD_EXTENSIONS)) + + paragraphs = text.split("\n\n") + parts = [] + for para in paragraphs: + para = para.strip() + if para: + lines = escape(para).split("\n") + parts.append("

" + "
".join(lines) + "

") + return mark_safe("".join(parts) if parts else f"

{escape(text)}

") diff --git a/apps/pages/admin.py b/apps/pages/admin.py index 131b286..57f4f2c 100644 --- a/apps/pages/admin.py +++ b/apps/pages/admin.py @@ -23,7 +23,8 @@ class FAQEntryAdmin(admin.ModelAdmin): search_fields = ("question", "answer") list_editable = ("order", "is_active") fieldsets = ( - (None, {"fields": ("question", "answer")}), + (None, {"fields": ("question",)}), + ("Answer", {"fields": ("answer_format", "answer")}), ("Settings", {"fields": ("order", "is_active")}), ) diff --git a/apps/pages/migrations/0003_faqentry_answer_format.py b/apps/pages/migrations/0003_faqentry_answer_format.py new file mode 100644 index 0000000..02d1c40 --- /dev/null +++ b/apps/pages/migrations/0003_faqentry_answer_format.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2026-05-14 05:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pages', '0002_contactsubmission'), + ] + + operations = [ + migrations.AddField( + model_name='faqentry', + name='answer_format', + field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20), + ), + ] diff --git a/apps/pages/models.py b/apps/pages/models.py index 816f437..737c502 100644 --- a/apps/pages/models.py +++ b/apps/pages/models.py @@ -1,5 +1,7 @@ from django.db import models +from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_PLAIN, render_content + class ContactSubmission(models.Model): name = models.CharField(max_length=200) @@ -21,6 +23,9 @@ class ContactSubmission(models.Model): class FAQEntry(models.Model): question = models.CharField(max_length=500) answer = models.TextField() + answer_format = models.CharField( + max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN + ) order = models.PositiveIntegerField(default=0) is_active = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) @@ -34,6 +39,10 @@ class FAQEntry(models.Model): def __str__(self): return self.question + @property + def rendered_answer(self): + return render_content(self.answer, self.answer_format) + class DownloadItem(models.Model): PLATFORM_WINDOWS = "windows" diff --git a/apps/pages/tests/test_views.py b/apps/pages/tests/test_views.py index 731bfab..547c7bf 100644 --- a/apps/pages/tests/test_views.py +++ b/apps/pages/tests/test_views.py @@ -1,7 +1,7 @@ from django.test import TestCase from django.urls import reverse -from apps.pages.models import DownloadItem, FAQEntry +from apps.pages.models import FAQEntry class HomeViewTest(TestCase): @@ -24,42 +24,6 @@ class AboutViewTest(TestCase): self.assertTemplateUsed(response, "pages/about.html") -class DownloadsViewTest(TestCase): - def setUp(self): - DownloadItem.objects.create( - name="Radiuma Desktop", - platform="windows", - version="1.0", - download_url="https://example.com/windows", - is_active=True, - ) - DownloadItem.objects.create( - name="Radiuma Desktop", - platform="macos", - version="Coming Soon", - download_url="#", - is_active=False, - ) - - def test_downloads_returns_200(self): - response = self.client.get(reverse("pages:downloads")) - self.assertEqual(response.status_code, 200) - - def test_downloads_uses_correct_template(self): - response = self.client.get(reverse("pages:downloads")) - self.assertTemplateUsed(response, "pages/downloads.html") - - def test_downloads_context_has_platform_keys(self): - response = self.client.get(reverse("pages:downloads")) - self.assertIn("windows_items", response.context) - self.assertIn("macos_items", response.context) - self.assertIn("linux_items", response.context) - - def test_windows_item_in_context(self): - response = self.client.get(reverse("pages:downloads")) - self.assertEqual(response.context["windows_items"].count(), 1) - - class FAQViewTest(TestCase): def setUp(self): FAQEntry.objects.create( @@ -107,8 +71,12 @@ class NavigationContextTest(TestCase): reverse("pages:about"), reverse("pages:faq"), reverse("pages:contact"), - reverse("pages:downloads"), + reverse("products:overview"), ] for url in urls: response = self.client.get(url) - self.assertIn("nav_main_products", response.context, f"Missing nav_main_products at {url}") + self.assertIn( + "nav_main_products", + response.context, + f"Missing nav_main_products at {url}", + ) diff --git a/apps/pages/urls.py b/apps/pages/urls.py index 9673e0c..a836d4e 100644 --- a/apps/pages/urls.py +++ b/apps/pages/urls.py @@ -7,7 +7,6 @@ app_name = "pages" urlpatterns = [ path("", views.HomeView.as_view(), name="home"), path("about/", views.AboutView.as_view(), name="about"), - path("downloads/", views.DownloadsView.as_view(), name="downloads"), path("faq/", views.FAQView.as_view(), name="faq"), path("contact/", views.ContactView.as_view(), name="contact"), ] diff --git a/apps/pages/views.py b/apps/pages/views.py index 7828410..3f8cd01 100644 --- a/apps/pages/views.py +++ b/apps/pages/views.py @@ -6,7 +6,7 @@ from django.views import View from django.views.generic import ListView, TemplateView from .forms import ContactForm -from .models import ContactSubmission, DownloadItem, FAQEntry +from .models import ContactSubmission, FAQEntry class HomeView(TemplateView): @@ -17,24 +17,6 @@ class AboutView(TemplateView): template_name = "pages/about.html" -class DownloadsView(ListView): - template_name = "pages/downloads.html" - context_object_name = "download_items" - - def get_queryset(self): - return DownloadItem.objects.filter(is_active=True).order_by("order", "platform") - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - all_items = DownloadItem.objects.order_by("order", "platform") - context["windows_items"] = all_items.filter( - platform=DownloadItem.PLATFORM_WINDOWS - ) - context["macos_items"] = all_items.filter(platform=DownloadItem.PLATFORM_MACOS) - context["linux_items"] = all_items.filter(platform=DownloadItem.PLATFORM_LINUX) - return context - - class FAQView(ListView): model = FAQEntry template_name = "pages/faq.html" diff --git a/apps/products/admin.py b/apps/products/admin.py index 9ba6a6d..a663949 100644 --- a/apps/products/admin.py +++ b/apps/products/admin.py @@ -1,12 +1,12 @@ from django.contrib import admin -from .models import Article, ArticleSection, MainProduct, SubProduct +from .models import Article, ArticleSection, MainProduct, SubProduct, SubProductVersion class ArticleSectionInline(admin.TabularInline): model = ArticleSection extra = 1 - fields = ("title", "value", "order") + fields = ("title", "value_format", "value", "order") ordering = ("order",) @@ -18,10 +18,28 @@ class ArticleInline(admin.StackedInline): show_change_link = True +class SubProductVersionInline(admin.TabularInline): + model = SubProductVersion + extra = 0 + ordering = ("order", "version") + fields = ( + "version", + "is_featured_stable", + "windows_download_url", + "macos_download_url", + "linux_download_url", + "source_code_url", + "package_resource_url", + "release_notes", + "is_active", + "order", + ) + + class SubProductInline(admin.StackedInline): model = SubProduct extra = 0 - fields = ("name", "slug", "short_description", "image", "order", "is_active") + fields = ("name", "slug", "distribution", "short_description", "image", "order", "is_active") ordering = ("order",) show_change_link = True prepopulated_fields = {"slug": ("name",)} @@ -36,7 +54,8 @@ class MainProductAdmin(admin.ModelAdmin): list_editable = ("order", "is_active") inlines = [SubProductInline] fieldsets = ( - (None, {"fields": ("name", "slug", "short_description", "description")}), + (None, {"fields": ("name", "slug", "short_description")}), + ("Description", {"fields": ("description_format", "description")}), ("Media", {"fields": ("image",)}), ("Settings", {"fields": ("order", "is_active")}), ) @@ -44,18 +63,23 @@ class MainProductAdmin(admin.ModelAdmin): @admin.register(SubProduct) class SubProductAdmin(admin.ModelAdmin): - list_display = ("name", "main_product", "order", "is_active", "created_at") - list_filter = ("is_active", "main_product") + list_display = ( + "name", + "main_product", + "distribution", + "order", + "is_active", + "created_at", + ) + list_filter = ("is_active", "distribution", "main_product") search_fields = ("name", "description", "main_product__name") prepopulated_fields = {"slug": ("name",)} list_editable = ("order", "is_active") raw_id_fields = ("main_product",) - inlines = [ArticleInline] + inlines = [ArticleInline, SubProductVersionInline] fieldsets = ( - ( - None, - {"fields": ("main_product", "name", "slug", "short_description", "description")}, - ), + (None, {"fields": ("main_product", "name", "slug", "distribution", "short_description")}), + ("Description", {"fields": ("description_format", "description")}), ("Media", {"fields": ("image",)}), ("Settings", {"fields": ("order", "is_active")}), ) @@ -70,14 +94,61 @@ class ArticleAdmin(admin.ModelAdmin): raw_id_fields = ("sub_product",) inlines = [ArticleSectionInline] fieldsets = ( - (None, {"fields": ("sub_product", "title", "description")}), + (None, {"fields": ("sub_product", "title")}), + ("Description", {"fields": ("description_format", "description")}), ("Settings", {"fields": ("order",)}), ) @admin.register(ArticleSection) class ArticleSectionAdmin(admin.ModelAdmin): - list_display = ("title", "article", "order") + 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 = ( + "sub_product", + "version", + "is_featured_stable", + "is_active", + "order", + ) + list_filter = ("is_active", "is_featured_stable", "sub_product__distribution") + search_fields = ("sub_product__name", "version") + list_editable = ("is_active", "order") + raw_id_fields = ("sub_product",) + fieldsets = ( + ( + None, + { + "fields": ( + "sub_product", + "version", + "is_featured_stable", + ) + }, + ), + ( + "Installable downloads", + { + "fields": ( + "windows_download_url", + "macos_download_url", + "linux_download_url", + "source_code_url", + ) + }, + ), + ("Package link", {"fields": ("package_resource_url",)}), + ("Details", {"fields": ("release_notes",)}), + ("Settings", {"fields": ("is_active", "order")}), + ) diff --git a/apps/products/migrations/0002_subproductrelease.py b/apps/products/migrations/0002_subproductrelease.py new file mode 100644 index 0000000..341ecc3 --- /dev/null +++ b/apps/products/migrations/0002_subproductrelease.py @@ -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')}, + }, + ), + ] diff --git a/apps/products/migrations/0003_article_description_format_and_more.py b/apps/products/migrations/0003_article_description_format_and_more.py new file mode 100644 index 0000000..72319b4 --- /dev/null +++ b/apps/products/migrations/0003_article_description_format_and_more.py @@ -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), + ), + ] diff --git a/apps/products/migrations/0004_distribution_and_versions.py b/apps/products/migrations/0004_distribution_and_versions.py new file mode 100644 index 0000000..81cb321 --- /dev/null +++ b/apps/products/migrations/0004_distribution_and_versions.py @@ -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", + ), + ] diff --git a/apps/products/models.py b/apps/products/models.py index cd87cad..9b61191 100644 --- a/apps/products/models.py +++ b/apps/products/models.py @@ -2,12 +2,25 @@ from django.db import models from django.urls import reverse from django.utils.text import slugify +from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_PLAIN, render_content + + +INSTALLABLE_PLATFORM_SPECS = ( + ("windows_download_url", "Windows", "images/icon-windows.svg"), + ("macos_download_url", "macOS", "images/icon-macos.svg"), + ("linux_download_url", "Linux", "images/icon-linux.svg"), + ("source_code_url", "Source code", None), +) + 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) order = models.PositiveIntegerField(default=0) is_active = models.BooleanField(default=True) @@ -19,6 +32,10 @@ class MainProduct(models.Model): 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 @@ -32,6 +49,13 @@ class MainProduct(models.Model): class SubProduct(models.Model): + DISTRIBUTION_INSTALLABLE = "installable" + DISTRIBUTION_PACKAGE = "package" + DISTRIBUTION_CHOICES = [ + (DISTRIBUTION_INSTALLABLE, "Installable application"), + (DISTRIBUTION_PACKAGE, "Package (external / non-installable)"), + ] + main_product = models.ForeignKey( MainProduct, on_delete=models.CASCADE, @@ -41,9 +65,18 @@ class SubProduct(models.Model): 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) order = models.PositiveIntegerField(default=0) is_active = models.BooleanField(default=True) + distribution = models.CharField( + max_length=20, + choices=DISTRIBUTION_CHOICES, + default=DISTRIBUTION_INSTALLABLE, + help_text="Only affects how releases appear on the public site.", + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -53,6 +86,10 @@ class SubProduct(models.Model): 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}" @@ -70,6 +107,15 @@ 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): sub_product = models.ForeignKey( @@ -79,6 +125,9 @@ class Article(models.Model): ) title = models.CharField(max_length=300) description = models.TextField() + description_format = models.CharField( + max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN + ) order = models.PositiveIntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -88,10 +137,64 @@ class Article(models.Model): verbose_name = "Article" verbose_name_plural = "Articles" + @property + def rendered_description(self): + return render_content(self.description, self.description_format) + def __str__(self): return self.title +class SubProductVersion(models.Model): + sub_product = models.ForeignKey( + SubProduct, + on_delete=models.CASCADE, + related_name="versions", + ) + version = models.CharField(max_length=80) + is_featured_stable = models.BooleanField( + default=False, + help_text="Highlighted as the main release on the product page.", + ) + windows_download_url = models.URLField(blank=True) + macos_download_url = models.URLField(blank=True) + linux_download_url = models.URLField(blank=True) + source_code_url = models.URLField(blank=True) + package_resource_url = models.URLField( + blank=True, + help_text="Shown on the site when set. For Resources it is the main Open link; for Installable it appears under the platform grid.", + ) + release_notes = models.TextField(blank=True) + is_active = models.BooleanField(default=True) + order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["order", "version", "pk"] + unique_together = [["sub_product", "version"]] + verbose_name = "Release version" + verbose_name_plural = "Release versions" + + def __str__(self): + return f"{self.sub_product.name} v{self.version}" + + def install_urls_for_specs(self, specs): + out = [] + for field_name, label, icon in specs: + url = getattr(self, field_name, "") or "" + if url.strip(): + out.append({"field": field_name, "label": label, "icon": icon, "url": url}) + return out + + def has_any_install_asset(self): + return any( + (getattr(self, f[0]) or "").strip() + for f in INSTALLABLE_PLATFORM_SPECS + ) + + def has_package_link(self): + return bool((self.package_resource_url or "").strip()) + + class ArticleSection(models.Model): article = models.ForeignKey( Article, @@ -100,6 +203,9 @@ class ArticleSection(models.Model): ) 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: @@ -107,5 +213,9 @@ class ArticleSection(models.Model): 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}" diff --git a/apps/products/tests/test_views.py b/apps/products/tests/test_views.py index 5c8ce10..8749440 100644 --- a/apps/products/tests/test_views.py +++ b/apps/products/tests/test_views.py @@ -135,3 +135,30 @@ class SubProductDetailViewTest(ProductViewsSetup): ) 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_stable=True, + windows_download_url="https://example.com/w", + is_active=True, + ) + SubProductVersion.objects.create( + sub_product=self.sub_product, + version="9.8", + is_featured_stable=False, + windows_download_url="https://example.com/w2", + is_active=True, + ) + url = reverse( + "products:sub_product_versions", + kwargs={"main_slug": "radiuma", "sub_slug": "image-processing"}, + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "products/sub_versions.html") diff --git a/apps/products/urls.py b/apps/products/urls.py index cb8a32f..65e13a4 100644 --- a/apps/products/urls.py +++ b/apps/products/urls.py @@ -11,6 +11,11 @@ urlpatterns = [ views.MainProductDetailView.as_view(), name="main_product_detail", ), + path( + "//versions/", + views.SubProductOlderVersionsView.as_view(), + name="sub_product_versions", + ), path( "//", views.SubProductDetailView.as_view(), diff --git a/apps/products/views.py b/apps/products/views.py index 7b887b3..f18801b 100644 --- a/apps/products/views.py +++ b/apps/products/views.py @@ -1,7 +1,22 @@ from django.shortcuts import get_object_or_404 from django.views.generic import DetailView, ListView, TemplateView -from .models import MainProduct, SubProduct +from .models import INSTALLABLE_PLATFORM_SPECS, MainProduct, SubProduct + + +def _featured_version(qs): + ordered = qs.order_by("order", "pk") + cand = ordered.filter(is_featured_stable=True).first() + return cand if cand else ordered.first() + + +def _install_specs_from_versions(version_list): + present = set() + for ver in version_list: + for field_name, *_ in INSTALLABLE_PLATFORM_SPECS: + if (getattr(ver, field_name) or "").strip(): + present.add(field_name) + return tuple(s for s in INSTALLABLE_PLATFORM_SPECS if s[0] in present) class ProductOverviewView(ListView): @@ -43,11 +58,110 @@ class SubProductDetailView(TemplateView): context["sub_product"] = sub_product context["articles"] = sub_product.articles.prefetch_related("sections").all() context["siblings"] = ( - SubProduct.objects.filter( - main_product=main_product, - is_active=True, - ) + 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) + + context["distribution_installable"] = ( + sub_product.distribution == SubProduct.DISTRIBUTION_INSTALLABLE + ) + context["distribution_package"] = ( + sub_product.distribution == SubProduct.DISTRIBUTION_PACKAGE + ) + context["show_releases_section"] = False + context["featured_version"] = None + context["featured_install_cells"] = [] + context["show_older_versions_link"] = False + context["package_versions"] = [] + + if sub_product.distribution == SubProduct.DISTRIBUTION_INSTALLABLE: + featured = _featured_version(active_versions) + if ( + featured + and not featured.has_any_install_asset() + and not (featured.package_resource_url or "").strip() + ): + for cand in active_versions.exclude(pk=featured.pk).order_by("order", "pk"): + if cand.has_any_install_asset() or (cand.package_resource_url or "").strip(): + featured = cand + break + context["featured_version"] = featured + if featured and ( + featured.has_any_install_asset() + or (featured.package_resource_url or "").strip() + ): + if featured.has_any_install_asset(): + specs = _install_specs_from_versions([featured]) + context["featured_install_cells"] = featured.install_urls_for_specs(specs) + context["show_releases_section"] = True + older_list = list( + active_versions.exclude(pk=featured.pk).order_by("order", "pk") + if featured else active_versions.order_by("order", "pk") + ) + context["show_older_versions_link"] = len(older_list) > 0 + + elif sub_product.distribution == SubProduct.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 + + +class SubProductOlderVersionsView(TemplateView): + template_name = "products/sub_versions.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) + featured = _featured_version(active_versions) + archive = list( + active_versions.exclude(pk=featured.pk).order_by("order", "pk") + if featured + else active_versions.order_by("order", "pk") + ) + context["main_product"] = main_product + context["sub_product"] = sub_product + context["featured_version"] = featured + context["archive_versions"] = archive + + if sub_product.distribution == SubProduct.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: + u = getattr(ver, field_name, "") or "" + u = u.strip() + cells.append( + {"field": field_name, "label": label, "icon": icon, "url": u} + ) + 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 diff --git a/requirements.txt b/requirements.txt index 783fabd..ffab9d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ gunicorn>=22.0.0 whitenoise[brotli]>=6.7.0 Pillow>=10.4.0 python-dotenv>=1.0.1 +markdown>=3.6 diff --git a/static/css/main.css b/static/css/main.css index 288a2b7..ac92d5b 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -1542,6 +1542,446 @@ h1, h2, h3, h4, h5, h6 { margin-top: 0.5rem; } +.release-block { + margin-bottom: 3.5rem; +} + +.release-block-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1.25rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--glass-border); +} + +.release-product-parent { + display: block; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent-cyan); + margin-bottom: 0.2rem; +} + +.release-product-name { + font-size: 1.35rem; + font-weight: 700; +} + +.release-channel { + margin-bottom: 1.25rem; +} + +.release-channel--previous { + opacity: 0.82; +} + +.release-channel-label { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 0.28rem 0.85rem; + border-radius: var(--radius-pill); + margin-bottom: 1rem; +} + +.release-channel-label--stable { + color: rgb(134, 239, 172); + background: rgba(34, 197, 94, 0.1); + border: 1px solid rgba(34, 197, 94, 0.2); +} + +.release-channel-label--previous { + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--glass-border); +} + +.download-card--previous { + opacity: 0.78; +} + +.download-btn--prev { + opacity: 0.9; +} + +.empty-state { + padding: 3rem; + text-align: center; + color: var(--text-secondary); +} + +/* ============================================================ + Rich Content (rendered Markdown / HTML / plain descriptions) + ============================================================ */ +.rich-content p { + margin-bottom: 0.85em; + line-height: 1.75; +} + +.rich-content p:last-child { margin-bottom: 0; } + +.rich-content h1, .rich-content h2, .rich-content h3, +.rich-content h4, .rich-content h5 { + font-weight: 700; + margin: 1.25em 0 0.5em; + line-height: 1.25; +} + +.rich-content h1 { font-size: 1.5rem; } +.rich-content h2 { font-size: 1.25rem; } +.rich-content h3 { font-size: 1.05rem; } + +.rich-content ul, .rich-content ol { + padding-left: 1.5em; + margin-bottom: 0.85em; +} + +.rich-content li { margin-bottom: 0.3em; line-height: 1.65; } + +.rich-content a { + color: var(--accent-blue-light); + text-decoration: underline; + text-underline-offset: 3px; +} + +.rich-content a:hover { color: var(--accent-cyan); } + +.rich-content code { + font-family: "SF Mono", "Fira Code", monospace; + font-size: 0.88em; + background: rgba(79, 142, 247, 0.1); + border: 1px solid rgba(79, 142, 247, 0.15); + padding: 0.15em 0.45em; + border-radius: 4px; +} + +.rich-content pre { + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + padding: 1rem 1.25rem; + overflow-x: auto; + margin-bottom: 1em; +} + +.rich-content pre code { + background: none; + border: none; + padding: 0; + font-size: 0.88rem; +} + +.rich-content blockquote { + border-left: 3px solid rgba(79, 142, 247, 0.45); + padding-left: 1rem; + color: var(--text-secondary); + margin: 1em 0; + font-style: italic; +} + +.rich-content table { + width: 100%; + border-collapse: collapse; + margin-bottom: 1em; + font-size: 0.9rem; +} + +.rich-content th, .rich-content td { + padding: 0.6rem 0.9rem; + border: 1px solid var(--glass-border); + text-align: left; +} + +.rich-content th { + background: rgba(79, 142, 247, 0.08); + font-weight: 600; +} + +/* ============================================================ + Sub-Product Inline Downloads + ============================================================ */ +.sub-downloads { + margin-top: 2rem; + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + padding: 1.5rem; + backdrop-filter: blur(12px); +} + +.sub-downloads-title { + font-size: 1rem; + font-weight: 700; + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1.25rem; + color: var(--text-primary); +} + +.sub-downloads-channel { + margin-bottom: 1.1rem; +} + +.sub-downloads-channel--prev { + opacity: 0.78; +} + +.sub-downloads-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.85rem; + margin-top: 0.6rem; + align-items: stretch; +} + +.sub-dl-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.45rem; + padding: 0.9rem 0.5rem; + border-radius: var(--radius-sm); + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.03); + text-align: center; +} + +.sub-dl-item--na { + opacity: 0.5; +} + +.sub-dl-icon-wrap { + width: 100%; + min-height: 40px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.sub-dl-icon-wrap .sub-dl-icon, +.sub-dl-icon-wrap .sub-dl-source-svg { + display: block; +} + +.sub-dl-item .btn-primary.btn--sm { + margin-top: auto; +} + +.sub-dl-source-mark { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted, #8892a6); +} + +.sub-dl-icon { + width: 32px; + height: 32px; +} + +.sub-dl-source-svg { + color: var(--text-secondary, #aab4c5); +} + +.sub-dl-platform { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-secondary); +} + +.pkg-version-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0; +} + +.pkg-version-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem 1rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--glass-border); +} + +.pkg-version-row:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.pkg-version-row:first-child { + padding-top: 0.5rem; +} + +.pkg-version-meta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + +.pkg-stable-inline { + font-size: 0.7rem; + padding: 0.2rem 0.55rem; +} + +.pkg-version-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-left: auto; +} + +.pkg-version-notes { + width: 100%; + margin: 0.35rem 0 0; + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.6; +} + +.sub-downloads--package .sub-package-stable { + margin-top: 0.6rem; +} + +.sub-package-row { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + margin-top: 0.75rem; +} + +.sub-release-notes { + margin-top: 1rem; + font-size: 0.88rem; + color: var(--text-secondary); + line-height: 1.65; +} + +.sub-install-extra-resource { + margin-top: 0.75rem; + margin-bottom: 0; + font-size: 0.92rem; +} + +.sub-install-extra-resource a { + font-weight: 600; +} + +.sub-older-versions-inner { + margin-top: 1rem; + margin-bottom: 0; + padding-top: 1rem; + border-top: 1px solid var(--glass-border); +} + +.link-arrow { + font-weight: 600; + color: var(--accent-strong, var(--accent, #6b9dff)); +} + +.versions-archive-section { + padding-top: 0; +} + +.versions-empty { + padding: 2rem 1.5rem; +} + +.versions-table-wrap { + padding: 0; + overflow-x: auto; +} + +.versions-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.versions-table th, +.versions-table td { + padding: 0.75rem 1rem; + text-align: left; + border-bottom: 1px solid var(--glass-border); +} + +.versions-table thead th { + background: rgba(79, 142, 247, 0.06); + font-weight: 600; +} + +.versions-table-link { + display: inline-flex; + align-items: center; +} + +.versions-table-icon { + display: block; +} + +.versions-table-text-link { + font-weight: 600; +} + +.versions-notes-row td { + background: rgba(0, 0, 0, 0.06); +} + +.versions-notes-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted, #8892a6); + display: block; + margin-bottom: 0.35rem; +} + +.versions-notes-body { + margin: 0; + font-size: 0.87rem; + color: var(--text-secondary); + line-height: 1.65; +} + +.versions-package-list { + display: flex; + flex-direction: column; + gap: 1rem; + list-style: none; + padding: 0; + margin: 0; +} + +.versions-package-card { + padding: 1.15rem 1.35rem; +} + +.versions-package-head { + display: flex; + align-items: center; + gap: 1rem; +} + +.versions-na { + opacity: 0.45; +} + .info-block { padding: 2.5rem; } diff --git a/templates/pages/downloads.html b/templates/pages/downloads.html deleted file mode 100644 index 6969c95..0000000 --- a/templates/pages/downloads.html +++ /dev/null @@ -1,119 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block title %}Downloads{% endblock %} -{% block meta_description %}Download Radiuma by Radiuma — available for Windows, macOS, and Linux.{% endblock %} - -{% block content %} - -
- -
-
-
Software
-

Downloads

-

Download the latest version of Radiuma for your platform.

-
-
-
- -
-
-

Available Platforms

-
- -
- -

Windows

-

Windows 10 and up (64-bit)

- {% if windows_items %} - {% for item in windows_items %} -
- v{{ item.version }} - {% if item.description %}

{{ item.description }}

{% endif %} - {% if item.download_url and item.download_url != '#' %} - - - Download - - {% endif %} -
- {% endfor %} - {% else %} - Coming Soon - {% endif %} -
- -
- -

macOS

-

macOS 11 Big Sur and up

- {% if macos_items %} - {% for item in macos_items %} -
- v{{ item.version }} - {% if item.description %}

{{ item.description }}

{% endif %} - {% if item.download_url and item.download_url != '#' %} - - - Download - - {% endif %} -
- {% endfor %} - {% else %} - Coming Soon - {% endif %} -
- -
- -

Linux

-

Ubuntu 20.04+ and compatible distributions

- {% if linux_items %} - {% for item in linux_items %} -
- v{{ item.version }} - {% if item.description %}

{{ item.description }}

{% endif %} - {% if item.download_url and item.download_url != '#' %} - - - Download - - {% endif %} -
- {% endfor %} - {% else %} - Coming Soon - {% endif %} -
- -
-
-
- -
-
-
-

Installation Notes

-

- If you have installed an older version of Radiuma, you can install the new version - over it without removing the previous installation. However, if you encounter any - problems, please remove the old version first before reinstalling. -

- View Full FAQ -
-
-
- -{% endblock %} diff --git a/templates/pages/faq.html b/templates/pages/faq.html index 15e6ec8..dcb6e73 100644 --- a/templates/pages/faq.html +++ b/templates/pages/faq.html @@ -45,7 +45,7 @@ aria-labelledby="faq-question-{{ entry.pk }}" hidden > -

{{ entry.answer }}

+
{{ entry.rendered_answer }}
{% endfor %} diff --git a/templates/pages/home.html b/templates/pages/home.html index 082a9c3..d7a8b46 100644 --- a/templates/pages/home.html +++ b/templates/pages/home.html @@ -31,7 +31,7 @@ including radiomics and machine learning analysis.

diff --git a/templates/partials/_footer.html b/templates/partials/_footer.html index 3bd946b..baf7a40 100644 --- a/templates/partials/_footer.html +++ b/templates/partials/_footer.html @@ -29,7 +29,6 @@
  • Home
  • What is Radiuma
  • Products
  • -
  • Downloads
  • FAQ
  • Contact
  • diff --git a/templates/partials/_navbar.html b/templates/partials/_navbar.html index 7610b10..cef9433 100644 --- a/templates/partials/_navbar.html +++ b/templates/partials/_navbar.html @@ -72,12 +72,6 @@ - -