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:
mohamad
2026-09-24 02:32:27 +03:30
parent 4fc4282f0f
commit 588d95f889
43 changed files with 703 additions and 1193 deletions
+43
View File
@@ -1,3 +1,4 @@
from django import forms
from django.contrib import admin
from django.http import HttpResponseRedirect
from django.urls import reverse
@@ -5,9 +6,36 @@ from django.urls import reverse
from .models import SiteBranding, SiteContact
class SiteBrandingForm(forms.ModelForm):
class Meta:
model = SiteBranding
fields = "__all__"
widgets = {
field: forms.TextInput(attrs={"type": "color"})
for field in (
"border_color",
"background_color",
"surface_color",
"primary_color",
"secondary_color",
"accent_color",
"text_color",
"muted_text_color",
)
}
@admin.register(SiteBranding)
class SiteBrandingAdmin(admin.ModelAdmin):
form = SiteBrandingForm
fieldsets = (
(
"Site identity",
{
"fields": ("site_name", "short_name", "tagline", "meta_description"),
"description": "Names and text used throughout the public website.",
},
),
(
"Brand assets",
{
@@ -44,6 +72,21 @@ class SiteBrandingAdmin(admin.ModelAdmin):
),
},
),
(
"Site colours",
{
"fields": (
"background_color",
"surface_color",
"primary_color",
"secondary_color",
"accent_color",
"text_color",
"muted_text_color",
),
"description": "Use six-digit hex colours. Changes apply across the public site.",
},
),
)
def has_add_permission(self, request):
+3 -123
View File
@@ -7,8 +7,6 @@ from apps.core.models import SiteBranding, SiteContact
from apps.pages.models import (
AboutSection,
AboutSectionItem,
DownloadItem,
FAQEntry,
HeroSection,
HomepageSection,
HomepageSectionItem,
@@ -214,62 +212,6 @@ MAIN_PRODUCTS = [
},
]
FAQ_ENTRIES = [
{
"question": "How is Communication to Care licensed?",
"answer": (
"Licensing and deployment terms are provided with each com2care release. "
"Contact support@com2care.com for research, institutional, or evaluation access."
),
"order": 1,
},
{
"question": "How do I acknowledge Communication to Care in my research?",
"answer": (
"Mention Communication to Care (com2care) and the software version used in your "
"methods section. Release-specific citation guidance can be requested from "
"support@com2care.com."
),
"order": 2,
},
{
"question": "Which operating systems does Communication to Care support?",
"answer": (
"Communication to Care currently fully supports Windows 10 and above (64-bit). "
"macOS and Linux packages are represented in this demonstration dataset as upcoming "
"channels. Check each product module page for current release information."
),
"order": 3,
},
{
"question": "Can I install a new version over an existing installation?",
"answer": (
"Yes, you can install the new version without removing the previous one. "
"However, if you encounter any problems after upgrading, we recommend "
"uninstalling the old version first, then performing a clean installation "
"of the new release."
),
"order": 4,
},
{
"question": "Is Communication to Care suitable for clinical use?",
"answer": (
"Communication to Care is designed and intended exclusively for research purposes. "
"It is not certified for clinical diagnostic use. Always consult with "
"qualified medical professionals for clinical decisions."
),
"order": 5,
},
{
"question": "Where can I get support or report issues?",
"answer": (
"Use the contact form or email support@com2care.com. Include the software version, "
"operating system, a short reproduction description, and non-sensitive logs when relevant."
),
"order": 6,
},
]
HOMEPAGE_SECTIONS = [
{
"section_type": HomepageSection.TYPE_FEATURES,
@@ -329,8 +271,8 @@ HOMEPAGE_SECTIONS = [
},
{
"section_type": HomepageSection.TYPE_PRODUCTS,
"badge": "Our Software",
"title": "Products",
"badge": "Our Projects",
"title": "Projects",
"description": "Explore our suite of medical imaging and radiomics tools.",
"order": 4,
"items": [],
@@ -447,44 +389,11 @@ ABOUT_SECTIONS = [
},
]
DOWNLOAD_ITEMS = [
{
"name": "com2care Desktop",
"platform": "windows",
"version": "1.0.0",
"download_url": "https://com2care.com/downloads/",
"description": "Demonstration release channel for Windows 10 and above (64-bit).",
"is_active": True,
"order": 1,
},
{
"name": "com2care Desktop",
"platform": "macos",
"version": "Coming Soon",
"download_url": "#",
"description": "macOS version is under development.",
"is_active": False,
"order": 2,
},
{
"name": "com2care Desktop",
"platform": "linux",
"version": "Coming Soon",
"download_url": "#",
"description": "Linux version is under development.",
"is_active": False,
"order": 3,
},
]
class Command(BaseCommand):
help = "Seed a complete com2care demonstration site."
CONTENT_MODELS = (
MainProduct,
FAQEntry,
DownloadItem,
HeroSection,
HomepageSection,
AboutSection,
@@ -520,15 +429,11 @@ class Command(BaseCommand):
Article.objects.all().delete()
SubProduct.objects.all().delete()
MainProduct.objects.all().delete()
FAQEntry.objects.all().delete()
DownloadItem.objects.all().delete()
HomepageSectionItem.objects.all().delete()
HomepageSection.objects.all().delete()
HeroSection.objects.all().delete()
main_product = self._seed_products()
self._seed_faq()
self._seed_downloads()
self._seed_homepage_sections()
self._seed_about_sections()
self._seed_product_videos(main_product)
@@ -617,18 +522,6 @@ class Command(BaseCommand):
self._update_instance(article_section, section_data)
return seeded_main_product
def _seed_faq(self):
for entry_data in FAQ_ENTRIES:
faq, created = FAQEntry.objects.get_or_create(
question=entry_data["question"],
defaults=entry_data,
)
if not created:
self._update_instance(faq, entry_data)
action = "Created" if created else "Updated"
self.stdout.write(f" {action} FAQ: {faq.question[:60]}...")
def _seed_homepage_sections(self):
for section_data in HOMEPAGE_SECTIONS:
items_data = section_data["items"]
@@ -711,19 +604,6 @@ class Command(BaseCommand):
if not created:
self._update_instance(video, data)
def _seed_downloads(self):
for item_data in DOWNLOAD_ITEMS:
item, created = DownloadItem.objects.get_or_create(
name=item_data["name"],
platform=item_data["platform"],
defaults=item_data,
)
if not created:
self._update_instance(item, item_data)
action = "Created" if created else "Updated"
self.stdout.write(f" {action} download: {item}")
def _seed_hero(self):
data = {
"badge": "Communication-first medical imaging",
@@ -735,7 +615,7 @@ class Command(BaseCommand):
"document analysis choices, and communicate results with clearer context."
),
"primary_cta_text": "Explore com2care",
"primary_cta_url": "/products/",
"primary_cta_url": "/projects/",
"secondary_cta_text": "Our Mission",
"secondary_cta_url": "/about/",
"image_alt": "A multidisciplinary care team collaborating around medical imaging",
@@ -0,0 +1,69 @@
# Generated by Django 5.2.16 on 2026-09-23 22:42
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0004_alter_sitebranding_hero_logo_alt_and_more'),
]
operations = [
migrations.AddField(
model_name='sitebranding',
name='accent_color',
field=models.CharField(default='#22d3ee', max_length=7, validators=[django.core.validators.RegexValidator(message='Enter a six-digit hex colour such as #4f8ef7.', regex='^#[0-9A-Fa-f]{6}$')]),
),
migrations.AddField(
model_name='sitebranding',
name='background_color',
field=models.CharField(default='#070713', max_length=7, validators=[django.core.validators.RegexValidator(message='Enter a six-digit hex colour such as #4f8ef7.', regex='^#[0-9A-Fa-f]{6}$')]),
),
migrations.AddField(
model_name='sitebranding',
name='meta_description',
field=models.CharField(blank=True, default='Communication to Care — Advanced Medical Imaging and Radiomics Solutions', max_length=300),
),
migrations.AddField(
model_name='sitebranding',
name='muted_text_color',
field=models.CharField(default='#a6abc2', max_length=7, validators=[django.core.validators.RegexValidator(message='Enter a six-digit hex colour such as #4f8ef7.', regex='^#[0-9A-Fa-f]{6}$')]),
),
migrations.AddField(
model_name='sitebranding',
name='primary_color',
field=models.CharField(default='#4f8ef7', max_length=7, validators=[django.core.validators.RegexValidator(message='Enter a six-digit hex colour such as #4f8ef7.', regex='^#[0-9A-Fa-f]{6}$')]),
),
migrations.AddField(
model_name='sitebranding',
name='secondary_color',
field=models.CharField(default='#a78bfa', max_length=7, validators=[django.core.validators.RegexValidator(message='Enter a six-digit hex colour such as #4f8ef7.', regex='^#[0-9A-Fa-f]{6}$')]),
),
migrations.AddField(
model_name='sitebranding',
name='short_name',
field=models.CharField(default='com2care', help_text='Compact name used in the navigation bar.', max_length=60),
),
migrations.AddField(
model_name='sitebranding',
name='site_name',
field=models.CharField(default='Communication to Care', max_length=120),
),
migrations.AddField(
model_name='sitebranding',
name='surface_color',
field=models.CharField(default='#10102a', max_length=7, validators=[django.core.validators.RegexValidator(message='Enter a six-digit hex colour such as #4f8ef7.', regex='^#[0-9A-Fa-f]{6}$')]),
),
migrations.AddField(
model_name='sitebranding',
name='tagline',
field=models.CharField(blank=True, default='Turning complex medical-imaging evidence into clearer, reproducible research conversations.', max_length=300),
),
migrations.AddField(
model_name='sitebranding',
name='text_color',
field=models.CharField(default='#eef2ff', max_length=7, validators=[django.core.validators.RegexValidator(message='Enter a six-digit hex colour such as #4f8ef7.', regex='^#[0-9A-Fa-f]{6}$')]),
),
]
+64 -2
View File
@@ -1,8 +1,12 @@
from django.core.validators import FileExtensionValidator
from django.core.validators import FileExtensionValidator, RegexValidator
from django.db import models
class SiteBranding(models.Model):
hex_color_validator = RegexValidator(
regex=r"^#[0-9A-Fa-f]{6}$",
message="Enter a six-digit hex colour such as #4f8ef7.",
)
OBJECT_FIT_COVER = "cover"
OBJECT_FIT_CONTAIN = "contain"
OBJECT_FIT_CHOICES = [
@@ -10,6 +14,23 @@ class SiteBranding(models.Model):
(OBJECT_FIT_CONTAIN, "Contain (fit inside square)"),
]
site_name = models.CharField(max_length=120, default="Communication to Care")
short_name = models.CharField(
max_length=60,
default="com2care",
help_text="Compact name used in the navigation bar.",
)
tagline = models.CharField(
max_length=300,
default="Turning complex medical-imaging evidence into clearer, reproducible research conversations.",
blank=True,
)
meta_description = models.CharField(
max_length=300,
default="Communication to Care — Advanced Medical Imaging and Radiomics Solutions",
blank=True,
)
icon = models.ImageField(
upload_to="branding/",
blank=True,
@@ -77,13 +98,34 @@ class SiteBranding(models.Model):
choices=OBJECT_FIT_CHOICES,
default=OBJECT_FIT_COVER,
)
background_color = models.CharField(
max_length=7, default="#070713", validators=[hex_color_validator]
)
surface_color = models.CharField(
max_length=7, default="#10102a", validators=[hex_color_validator]
)
primary_color = models.CharField(
max_length=7, default="#4f8ef7", validators=[hex_color_validator]
)
secondary_color = models.CharField(
max_length=7, default="#a78bfa", validators=[hex_color_validator]
)
accent_color = models.CharField(
max_length=7, default="#22d3ee", validators=[hex_color_validator]
)
text_color = models.CharField(
max_length=7, default="#eef2ff", validators=[hex_color_validator]
)
muted_text_color = models.CharField(
max_length=7, default="#a6abc2", validators=[hex_color_validator]
)
class Meta:
verbose_name = "Site Branding"
verbose_name_plural = "Site Branding"
def __str__(self):
return "Site Branding"
return self.site_name
@classmethod
def load(cls):
@@ -108,6 +150,26 @@ class SiteBranding(models.Model):
def footer_icon_style(self):
return self.icon_style(self.footer_icon_size)
@property
def theme_style(self):
return ";".join(
(
f"--bg-primary:{self.background_color}",
f"--bg-secondary:{self.background_color}",
f"--bg-surface:{self.surface_color}",
f"--accent-blue:{self.primary_color}",
f"--accent-blue-light:{self.primary_color}",
f"--accent-purple:{self.secondary_color}",
f"--accent-cyan:{self.accent_color}",
f"--text-primary:{self.text_color}",
f"--text-secondary:{self.muted_text_color}",
f"--text-muted:{self.muted_text_color}",
f"--text-link:{self.primary_color}",
f"--gradient-brand:linear-gradient(135deg,{self.primary_color} 0%,{self.secondary_color} 100%)",
f"--gradient-text:linear-gradient(135deg,{self.primary_color} 0%,{self.secondary_color} 50%,{self.accent_color} 100%)",
)
)
class SiteContact(models.Model):
support_email = models.EmailField(
+13
View File
@@ -59,3 +59,16 @@ class SiteBrandingTemplateTests(TestCase):
self.assertContains(response, "images/com2care-mark.svg")
self.assertContains(response, "images/com2care-care-team.jpg")
def test_site_identity_and_theme_are_admin_configurable(self):
branding = SiteBranding.load()
branding.site_name = "Care Research Network"
branding.short_name = "CRN"
branding.primary_color = "#123456"
branding.save()
response = self.client.get(reverse("pages:home"))
self.assertContains(response, "Care Research Network")
self.assertContains(response, ">CRN</span>")
self.assertContains(response, "--accent-blue:#123456")
+1 -1
View File
@@ -117,7 +117,7 @@ class VideoBlockMixin(models.Model):
)
video_styled_background = models.BooleanField(
default=False,
help_text="Glass panel with ambient glow (similar to the Downloads section).",
help_text="Show the video in a glass panel with an ambient glow.",
)
description_format = models.CharField(
max_length=20,
+16 -28
View File
@@ -9,13 +9,12 @@ from apps.core.video import VIDEO_ADMIN_FIELDSET
from .models import (
AboutSection,
AboutSectionItem,
BlogPost,
ContactSubmission,
ContactSubmissionAttachment,
CustomPage,
CustomPageSection,
CustomPageSectionItem,
DownloadItem,
FAQEntry,
HeroSection,
HomepageSection,
HomepageSectionItem,
@@ -74,7 +73,6 @@ class HomepageSectionAdmin(admin.ModelAdmin):
def video_preview(self, obj):
return video_admin_preview(obj)
class AboutSectionItemInline(admin.TabularInline):
model = AboutSectionItem
extra = 1
@@ -154,19 +152,6 @@ class ContactSubmissionAdmin(admin.ModelAdmin):
)
@admin.register(FAQEntry)
class FAQEntryAdmin(admin.ModelAdmin):
list_display = ("question", "order", "is_active", "created_at")
list_filter = ("is_active",)
search_fields = ("question", "answer")
list_editable = ("order", "is_active")
fieldsets = (
(None, {"fields": ("question",)}),
("Answer", {"fields": ("answer_format", "answer")}),
("Settings", {"fields": ("order", "is_active")}),
)
class CustomPageSectionItemInline(admin.TabularInline):
model = CustomPageSectionItem
extra = 1
@@ -290,6 +275,21 @@ class CustomPageAdmin(admin.ModelAdmin):
admin.site.register(CustomPageSection, CustomPageSectionAdmin)
@admin.register(BlogPost)
class BlogPostAdmin(admin.ModelAdmin):
list_display = ("title", "status", "published_at", "updated_at")
list_filter = ("status", "published_at")
search_fields = ("title", "excerpt", "content", "author_name")
prepopulated_fields = {"slug": ("title",)}
date_hierarchy = "published_at"
fieldsets = (
(None, {"fields": ("title", "slug", "excerpt")}),
("Article", {"fields": ("content_format", "content")}),
("Cover image", {"fields": ("cover_image", "cover_image_alt")}),
("Publishing", {"fields": ("status", "published_at", "author_name")}),
)
@admin.register(PageVideo)
class PageVideoAdmin(admin.ModelAdmin):
list_display = ("page", "title", "video_source", "video_size", "order", "is_active")
@@ -306,15 +306,3 @@ class PageVideoAdmin(admin.ModelAdmin):
@admin.display(description="Preview")
def video_preview(self, obj):
return video_admin_preview(obj)
@admin.register(DownloadItem)
class DownloadItemAdmin(admin.ModelAdmin):
list_display = ("name", "platform", "version", "is_active", "order")
list_filter = ("platform", "is_active")
search_fields = ("name", "description")
list_editable = ("order", "is_active")
fieldsets = (
(None, {"fields": ("name", "platform", "version", "download_url", "description")}),
("Settings", {"fields": ("order", "is_active")}),
)
@@ -0,0 +1,72 @@
# Generated by Django 5.2.16 on 2026-09-23 22:42
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0018_alter_herosection_title'),
]
operations = [
migrations.CreateModel(
name='BlogPost',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=240)),
('slug', models.SlugField(blank=True, max_length=240, unique=True)),
('excerpt', models.CharField(blank=True, help_text='Short summary shown on the blog listing and in search previews.', max_length=400)),
('content', models.TextField()),
('content_format', models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='markdown', max_length=20)),
('cover_image', models.ImageField(blank=True, null=True, upload_to='blog/')),
('cover_image_alt', models.CharField(blank=True, max_length=240)),
('author_name', models.CharField(blank=True, max_length=120)),
('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published')], default='draft', max_length=20)),
('published_at', models.DateTimeField(default=django.utils.timezone.now)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Blog post',
'verbose_name_plural': 'Blog posts',
'ordering': ['-published_at', '-pk'],
},
),
migrations.AlterField(
model_name='aboutsection',
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='custompagesection',
name='section_type',
field=models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('features', 'Features Grid'), ('screenshots', 'Screenshots Gallery'), ('products', 'Projects Grid'), ('products_catalog', 'Projects with sub-projects'), ('problems', 'Problems / Value Proposition'), ('supporters', 'Supporters'), ('about_strip', 'About Strip'), ('video', 'Video')], default='custom', max_length=30),
),
migrations.AlterField(
model_name='custompagesection',
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='homepagesection',
name='section_type',
field=models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Projects'), ('products_catalog', 'Projects with sub-projects'), ('problems', 'Problems / Value Proposition'), ('about_strip', 'About Strip'), ('supporters', 'Supporters'), ('video', 'Video')], max_length=30),
),
migrations.AlterField(
model_name='homepagesection',
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='pagevideo',
name='page',
field=models.CharField(choices=[('contact', 'Become a Member')], max_length=20),
),
migrations.AlterField(
model_name='pagevideo',
name='video_styled_background',
field=models.BooleanField(default=False, help_text='Show the video in a glass panel with an ambient glow.'),
),
]
+65 -9
View File
@@ -1,5 +1,7 @@
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.text import slugify
from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_MARKDOWN, FORMAT_PLAIN, render_content
@@ -13,8 +15,10 @@ RESERVED_PAGE_SLUGS = frozenset({
"admin",
"about",
"contact",
"blog",
"faq",
"home",
"projects",
"products",
"static",
"media",
@@ -55,8 +59,8 @@ class HomepageSection(VideoBlockMixin, models.Model):
TYPE_CHOICES = [
(TYPE_FEATURES, "Features"),
(TYPE_SCREENSHOTS, "Screenshots Gallery"),
(TYPE_PRODUCTS, "Products"),
(TYPE_PRODUCTS_CATALOG, "Products with Sub-products"),
(TYPE_PRODUCTS, "Projects"),
(TYPE_PRODUCTS_CATALOG, "Projects with sub-projects"),
(TYPE_PROBLEMS, "Problems / Value Proposition"),
(TYPE_ABOUT_STRIP, "About Strip"),
(TYPE_SUPPORTERS, "Supporters"),
@@ -310,6 +314,62 @@ class CustomPage(models.Model):
super().save(*args, **kwargs)
class BlogPost(models.Model):
STATUS_DRAFT = "draft"
STATUS_PUBLISHED = "published"
STATUS_CHOICES = [
(STATUS_DRAFT, "Draft"),
(STATUS_PUBLISHED, "Published"),
]
title = models.CharField(max_length=240)
slug = models.SlugField(max_length=240, unique=True, blank=True)
excerpt = models.CharField(
max_length=400,
blank=True,
help_text="Short summary shown on the blog listing and in search previews.",
)
content = models.TextField()
content_format = models.CharField(
max_length=20,
choices=CONTENT_FORMAT_CHOICES,
default=FORMAT_MARKDOWN,
)
cover_image = models.ImageField(upload_to="blog/", blank=True, null=True)
cover_image_alt = models.CharField(max_length=240, blank=True)
author_name = models.CharField(max_length=120, blank=True)
status = models.CharField(
max_length=20, choices=STATUS_CHOICES, default=STATUS_DRAFT
)
published_at = models.DateTimeField(default=timezone.now)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-published_at", "-pk"]
verbose_name = "Blog post"
verbose_name_plural = "Blog posts"
def __str__(self):
return self.title
@property
def rendered_content(self):
return render_content(self.content, self.content_format)
@property
def is_public(self):
return self.status == self.STATUS_PUBLISHED and self.published_at <= timezone.now()
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title)
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse("pages:blog_detail", kwargs={"slug": self.slug})
class CustomPageSection(VideoBlockMixin, models.Model):
TYPE_HERO = "hero"
TYPE_INTRO = "intro"
@@ -323,7 +383,6 @@ class CustomPageSection(VideoBlockMixin, models.Model):
TYPE_PROBLEMS = "problems"
TYPE_SUPPORTERS = "supporters"
TYPE_ABOUT_STRIP = "about_strip"
TYPE_FAQ = "faq"
TYPE_VIDEO = "video"
TYPE_CHOICES = [
@@ -334,12 +393,11 @@ class CustomPageSection(VideoBlockMixin, models.Model):
(TYPE_CUSTOM, "Custom Content"),
(TYPE_FEATURES, "Features Grid"),
(TYPE_SCREENSHOTS, "Screenshots Gallery"),
(TYPE_PRODUCTS, "Products Grid"),
(TYPE_PRODUCTS_CATALOG, "Products with Sub-products"),
(TYPE_PRODUCTS, "Projects Grid"),
(TYPE_PRODUCTS_CATALOG, "Projects with sub-projects"),
(TYPE_PROBLEMS, "Problems / Value Proposition"),
(TYPE_SUPPORTERS, "Supporters"),
(TYPE_ABOUT_STRIP, "About Strip"),
(TYPE_FAQ, "FAQ Accordion"),
(TYPE_VIDEO, "Video"),
]
@@ -377,11 +435,9 @@ class CustomPageSection(VideoBlockMixin, models.Model):
class PageVideo(VideoBlockMixin, models.Model):
PAGE_CONTACT = "contact"
PAGE_FAQ = "faq"
PAGE_CHOICES = [
(PAGE_CONTACT, "Contact"),
(PAGE_FAQ, "FAQ"),
(PAGE_CONTACT, "Become a Member"),
]
page = models.CharField(max_length=20, choices=PAGE_CHOICES)
+10 -10
View File
@@ -103,37 +103,37 @@ class PageVideoTest(TestCase):
self.assertContains(response, "Intro")
self.assertContains(response, "youtube-nocookie.com/embed/dQw4w9WgXcQ")
def test_faq_page_video(self):
def test_membership_page_video(self):
PageVideo.objects.create(
page=PageVideo.PAGE_FAQ,
page=PageVideo.PAGE_CONTACT,
title="Tutorial",
video_source="youtube",
video_url="https://youtu.be/dQw4w9WgXcQ",
is_active=True,
)
response = self.client.get(reverse("pages:faq"))
response = self.client.get(reverse("pages:contact"))
self.assertContains(response, "Tutorial")
def test_page_shows_three_video_preview_and_archive_link(self):
self._create_page_videos(PageVideo.PAGE_FAQ, 8)
self._create_page_videos(PageVideo.PAGE_CONTACT, 8)
response = self.client.get(reverse("pages:faq"))
response = self.client.get(reverse("pages:contact"))
self.assertEqual(response.content.count(b"youtube-nocookie.com/embed"), 3)
self.assertContains(
response,
reverse(
"pages:video_archive",
kwargs={"library": PageVideo.PAGE_FAQ},
kwargs={"library": PageVideo.PAGE_CONTACT},
),
)
self.assertContains(response, "More videos")
def test_page_video_archive_is_paginated(self):
self._create_page_videos(PageVideo.PAGE_FAQ, 8)
self._create_page_videos(PageVideo.PAGE_CONTACT, 8)
archive_url = reverse(
"pages:video_archive",
kwargs={"library": PageVideo.PAGE_FAQ},
kwargs={"library": PageVideo.PAGE_CONTACT},
)
first_page = self.client.get(archive_url)
@@ -248,7 +248,7 @@ class VideoDescriptionFormatTest(TestCase):
def test_markdown_description_renders(self):
PageVideo.objects.create(
page=PageVideo.PAGE_FAQ,
page=PageVideo.PAGE_CONTACT,
title="Guide",
description="**Bold** intro",
description_format="markdown",
@@ -256,5 +256,5 @@ class VideoDescriptionFormatTest(TestCase):
video_url="https://youtu.be/dQw4w9WgXcQ",
is_active=True,
)
response = self.client.get(reverse("pages:faq"))
response = self.client.get(reverse("pages:contact"))
self.assertContains(response, "<strong>Bold</strong>")
+34 -27
View File
@@ -1,7 +1,8 @@
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from apps.pages.models import FAQEntry
from apps.pages.models import BlogPost, HomepageSection
class HomeViewTest(TestCase):
@@ -13,6 +14,16 @@ class HomeViewTest(TestCase):
response = self.client.get(reverse("pages:home"))
self.assertTemplateUsed(response, "pages/home.html")
def test_section_description_renders_markdown_and_html(self):
HomepageSection.objects.create(
section_type=HomepageSection.TYPE_FEATURES,
title="Rich section",
description="**Formatted** content",
description_format="markdown",
)
response = self.client.get(reverse("pages:home"))
self.assertContains(response, "<strong>Formatted</strong>")
class AboutViewTest(TestCase):
def test_about_returns_200(self):
@@ -24,34 +35,30 @@ class AboutViewTest(TestCase):
self.assertTemplateUsed(response, "pages/about.html")
class FAQViewTest(TestCase):
class BlogViewTest(TestCase):
def setUp(self):
FAQEntry.objects.create(
question="What is the license?",
answer="It is CC BY-NC-SA.",
order=1,
is_active=True,
)
FAQEntry.objects.create(
question="Hidden question",
answer="Hidden answer",
order=2,
is_active=False,
self.post = BlogPost.objects.create(
title="Research update",
slug="research-update",
excerpt="A short summary.",
content="## Results\n\n**Promising** findings.",
content_format="markdown",
status=BlogPost.STATUS_PUBLISHED,
published_at=timezone.now(),
)
def test_faq_returns_200(self):
response = self.client.get(reverse("pages:faq"))
self.assertEqual(response.status_code, 200)
def test_blog_list_and_detail_render_published_post(self):
listing = self.client.get(reverse("pages:blog_list"))
detail = self.client.get(self.post.get_absolute_url())
self.assertContains(listing, self.post.title)
self.assertContains(detail, "<h2>Results</h2>")
self.assertContains(detail, "<strong>Promising</strong>")
def test_faq_uses_correct_template(self):
response = self.client.get(reverse("pages:faq"))
self.assertTemplateUsed(response, "pages/faq.html")
def test_faq_only_shows_active_entries(self):
response = self.client.get(reverse("pages:faq"))
entries = response.context["faq_entries"]
self.assertEqual(entries.count(), 1)
self.assertEqual(entries.first().question, "What is the license?")
def test_draft_post_is_not_public(self):
self.post.status = BlogPost.STATUS_DRAFT
self.post.save(update_fields=["status"])
self.assertNotContains(self.client.get(reverse("pages:blog_list")), self.post.title)
self.assertEqual(self.client.get(self.post.get_absolute_url()).status_code, 404)
class ContactViewTest(TestCase):
@@ -69,7 +76,7 @@ class NavigationContextTest(TestCase):
urls = [
reverse("pages:home"),
reverse("pages:about"),
reverse("pages:faq"),
reverse("pages:blog_list"),
reverse("pages:contact"),
reverse("products:overview"),
]
@@ -85,7 +92,7 @@ class NavigationContextTest(TestCase):
urls = [
reverse("pages:home"),
reverse("pages:about"),
reverse("pages:faq"),
reverse("pages:blog_list"),
reverse("pages:contact"),
]
for url in urls:
+2 -1
View File
@@ -7,8 +7,9 @@ app_name = "pages"
urlpatterns = [
path("", views.HomeView.as_view(), name="home"),
path("about/", views.AboutView.as_view(), name="about"),
path("faq/", views.FAQView.as_view(), name="faq"),
path("contact/", views.ContactView.as_view(), name="contact"),
path("blog/", views.BlogPostListView.as_view(), name="blog_list"),
path("blog/<slug:slug>/", views.BlogPostDetailView.as_view(), name="blog_detail"),
path(
"videos/<slug:library>/",
views.PageVideoArchiveView.as_view(),
+27 -31
View File
@@ -4,6 +4,7 @@ from django.db.models import Prefetch
from django.http import Http404, JsonResponse
from django.shortcuts import render
from django.urls import reverse
from django.utils import timezone
from django.views import View
from django.views.generic import DetailView, ListView, TemplateView
@@ -12,10 +13,10 @@ from apps.products.models import MainProduct, SubProduct
from .forms import ContactForm
from .models import (
AboutSection,
BlogPost,
ContactSubmission,
ContactSubmissionAttachment,
CustomPage,
FAQEntry,
HeroSection,
HomepageSection,
PageVideo,
@@ -79,31 +80,6 @@ class AboutView(TemplateView):
return ctx
class FAQView(ListView):
model = FAQEntry
template_name = "pages/faq.html"
context_object_name = "faq_entries"
queryset = FAQEntry.objects.filter(is_active=True)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
videos = PageVideo.objects.filter(
page=PageVideo.PAGE_FAQ,
is_active=True,
).order_by("order", "pk")
preview = _video_preview_context(
videos,
reverse(
"pages:video_archive",
kwargs={"library": PageVideo.PAGE_FAQ},
),
)
ctx["faq_videos"] = preview["videos"]
ctx["videos_total_count"] = preview["videos_total_count"]
ctx["videos_archive_url"] = preview["videos_archive_url"]
return ctx
class ContactView(View):
template_name = "pages/contact.html"
@@ -198,6 +174,31 @@ class CustomPageView(DetailView):
return ctx
class BlogPostListView(ListView):
model = BlogPost
template_name = "pages/blog_list.html"
context_object_name = "posts"
paginate_by = 9
def get_queryset(self):
return BlogPost.objects.filter(
status=BlogPost.STATUS_PUBLISHED,
published_at__lte=timezone.now(),
)
class BlogPostDetailView(DetailView):
model = BlogPost
template_name = "pages/blog_detail.html"
context_object_name = "post"
def get_queryset(self):
return BlogPost.objects.filter(
status=BlogPost.STATUS_PUBLISHED,
published_at__lte=timezone.now(),
)
class PageVideoArchiveView(ListView):
model = PageVideo
template_name = "videos/archive.html"
@@ -210,11 +211,6 @@ class PageVideoArchiveView(ListView):
"Guides and updates from the Communication to Care team.",
"pages:contact",
),
PageVideo.PAGE_FAQ: (
"FAQ videos",
"Video answers to common questions.",
"pages:faq",
),
}
def get_page_config(self):
+5 -122
View File
@@ -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 "—"
+1 -1
View File
@@ -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
@@ -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
View File
@@ -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)
+7 -201
View File
@@ -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")
-15
View File
@@ -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
View File
@@ -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