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:
@@ -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):
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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")}),
|
||||
)
|
||||
|
||||
+72
@@ -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
@@ -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)
|
||||
|
||||
@@ -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>")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -3,7 +3,7 @@ from django.contrib import admin
|
||||
from apps.core.admin_video import video_admin_preview
|
||||
from apps.core.video import VIDEO_ADMIN_FIELDSET
|
||||
|
||||
from .models import Article, ArticleCitation, ArticleSection, MainProduct, ProductVideo, SubProduct, SubProductVersion
|
||||
from .models import Article, ArticleCitation, ArticleSection, MainProduct, ProductVideo, SubProduct
|
||||
|
||||
|
||||
class ArticleCitationInline(admin.TabularInline):
|
||||
@@ -38,41 +38,6 @@ class MainProductArticleInline(admin.StackedInline):
|
||||
show_change_link = True
|
||||
|
||||
|
||||
RELEASE_VERSION_INLINE_FIELDS = (
|
||||
"version",
|
||||
"release_channel",
|
||||
"channel_label",
|
||||
"is_featured",
|
||||
"show_on_product_page",
|
||||
"windows_download_url",
|
||||
"macos_download_url",
|
||||
"linux_download_url",
|
||||
"source_code_url",
|
||||
"package_resource_url",
|
||||
"release_notes",
|
||||
"is_active",
|
||||
"order",
|
||||
)
|
||||
|
||||
|
||||
class SubProductVersionInline(admin.TabularInline):
|
||||
model = SubProductVersion
|
||||
fk_name = "sub_product"
|
||||
extra = 0
|
||||
ordering = ("order", "version")
|
||||
fields = RELEASE_VERSION_INLINE_FIELDS
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class MainProductVersionInline(admin.TabularInline):
|
||||
model = SubProductVersion
|
||||
fk_name = "main_product"
|
||||
extra = 0
|
||||
ordering = ("order", "version")
|
||||
fields = RELEASE_VERSION_INLINE_FIELDS
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class ProductVideoInline(admin.StackedInline):
|
||||
model = ProductVideo
|
||||
fk_name = "main_product"
|
||||
@@ -98,7 +63,7 @@ class ProductVideoInline(admin.StackedInline):
|
||||
class SubProductInline(admin.StackedInline):
|
||||
model = SubProduct
|
||||
extra = 0
|
||||
fields = ("name", "slug", "distribution", "short_description", "image", "logo", "show_on_homepage", "homepage_order", "order", "is_active")
|
||||
fields = ("name", "slug", "short_description", "image", "logo", "show_on_homepage", "homepage_order", "order", "is_active")
|
||||
ordering = ("order",)
|
||||
show_change_link = True
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
@@ -111,7 +76,7 @@ class MainProductAdmin(admin.ModelAdmin):
|
||||
search_fields = ("name", "description")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
|
||||
inlines = [MainProductArticleInline, ProductVideoInline, MainProductVersionInline, SubProductInline]
|
||||
inlines = [MainProductArticleInline, ProductVideoInline, SubProductInline]
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
@@ -120,11 +85,6 @@ class MainProductAdmin(admin.ModelAdmin):
|
||||
"name",
|
||||
"slug",
|
||||
"short_description",
|
||||
"distribution",
|
||||
"package_resource_button_text",
|
||||
"package_source_button_text",
|
||||
"downloads_section_link_text",
|
||||
"downloads_section_link_url",
|
||||
)
|
||||
},
|
||||
),
|
||||
@@ -162,19 +122,18 @@ class SubProductAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"main_product",
|
||||
"distribution",
|
||||
"show_on_homepage",
|
||||
"homepage_order",
|
||||
"order",
|
||||
"is_active",
|
||||
"created_at",
|
||||
)
|
||||
list_filter = ("is_active", "distribution", "main_product", "show_on_homepage")
|
||||
list_filter = ("is_active", "main_product", "show_on_homepage")
|
||||
search_fields = ("name", "description", "main_product__name")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
|
||||
raw_id_fields = ("main_product",)
|
||||
inlines = [SubProductArticleInline, SubProductVideoInline, SubProductVersionInline]
|
||||
inlines = [SubProductArticleInline, SubProductVideoInline]
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
@@ -183,12 +142,7 @@ class SubProductAdmin(admin.ModelAdmin):
|
||||
"main_product",
|
||||
"name",
|
||||
"slug",
|
||||
"distribution",
|
||||
"short_description",
|
||||
"package_resource_button_text",
|
||||
"package_source_button_text",
|
||||
"downloads_section_link_text",
|
||||
"downloads_section_link_url",
|
||||
)
|
||||
},
|
||||
),
|
||||
@@ -261,74 +215,3 @@ class ArticleSectionAdmin(admin.ModelAdmin):
|
||||
("Content", {"fields": ("value_format", "value")}),
|
||||
("Settings", {"fields": ("order",)}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(SubProductVersion)
|
||||
class SubProductVersionAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"parent",
|
||||
"version",
|
||||
"release_channel",
|
||||
"channel_label",
|
||||
"is_featured",
|
||||
"show_on_product_page",
|
||||
"is_active",
|
||||
"order",
|
||||
)
|
||||
list_filter = ("is_active", "release_channel", "is_featured", "show_on_product_page", "main_product", "sub_product__main_product")
|
||||
search_fields = ("main_product__name", "sub_product__name", "version")
|
||||
list_editable = ("is_active", "order")
|
||||
raw_id_fields = ("main_product", "sub_product")
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"description": (
|
||||
"Set a preset channel badge (Stable, Beta, Previous, etc.) or write a custom "
|
||||
"label. Mark one release as Primary (featured) for the main download block. "
|
||||
"Enable “Show on product page” for additional inline channels such as beta or "
|
||||
"previous versions."
|
||||
),
|
||||
"fields": (
|
||||
"main_product",
|
||||
"sub_product",
|
||||
"version",
|
||||
"release_channel",
|
||||
"channel_label",
|
||||
"is_featured",
|
||||
"show_on_product_page",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Installable downloads",
|
||||
{
|
||||
"description": (
|
||||
"For each platform, set an external URL, upload a file, both, or neither. "
|
||||
"When a URL is set it is used on the site; otherwise an uploaded file is served. "
|
||||
"To delete an uploaded file from the server, open this release, check Clear next to the file, and Save."
|
||||
),
|
||||
"fields": (
|
||||
"windows_download_url",
|
||||
"windows_download_file",
|
||||
"macos_download_url",
|
||||
"macos_download_file",
|
||||
"linux_download_url",
|
||||
"linux_download_file",
|
||||
"source_code_url",
|
||||
"source_code_file",
|
||||
),
|
||||
},
|
||||
),
|
||||
("Package link", {"fields": ("package_resource_url",)}),
|
||||
("Details", {"fields": ("release_notes",)}),
|
||||
("Settings", {"fields": ("is_active", "order")}),
|
||||
)
|
||||
|
||||
@admin.display(description="Parent")
|
||||
def parent(self, obj):
|
||||
if obj.main_product_id:
|
||||
return obj.main_product
|
||||
if obj.sub_product_id:
|
||||
return obj.sub_product
|
||||
return "—"
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.apps import AppConfig
|
||||
class ProductsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.products"
|
||||
verbose_name = "Products"
|
||||
verbose_name = "Projects"
|
||||
|
||||
def ready(self):
|
||||
from . import signals # noqa: F401
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
# Generated by Django 5.2.16 on 2026-09-23 22:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0021_release_channels'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='mainproduct',
|
||||
options={'ordering': ['order', 'name'], 'verbose_name': 'Main Project', 'verbose_name_plural': 'Main Projects'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='productvideo',
|
||||
options={'ordering': ['order', 'pk'], 'verbose_name': 'Project Video', 'verbose_name_plural': 'Project Videos'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='subproduct',
|
||||
options={'ordering': ['order', 'name'], 'verbose_name': 'Sub-project', 'verbose_name_plural': 'Sub-projects'},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='mainproduct',
|
||||
name='distribution',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='mainproduct',
|
||||
name='downloads_section_link_text',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='mainproduct',
|
||||
name='downloads_section_link_url',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='mainproduct',
|
||||
name='package_resource_button_text',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='mainproduct',
|
||||
name='package_source_button_text',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='subproduct',
|
||||
name='distribution',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='subproduct',
|
||||
name='downloads_section_link_text',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='subproduct',
|
||||
name='downloads_section_link_url',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='subproduct',
|
||||
name='package_resource_button_text',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='subproduct',
|
||||
name='package_source_button_text',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='mainproduct',
|
||||
name='homepage_order',
|
||||
field=models.PositiveIntegerField(default=0, help_text='Order within the homepage Projects section.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='mainproduct',
|
||||
name='show_on_homepage',
|
||||
field=models.BooleanField(default=False, help_text='Display this project in the homepage Projects section.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='productvideo',
|
||||
name='video_styled_background',
|
||||
field=models.BooleanField(default=False, help_text='Show the video in a glass panel with an ambient glow.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subproduct',
|
||||
name='homepage_order',
|
||||
field=models.PositiveIntegerField(default=0, help_text='Order within the homepage Projects section.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subproduct',
|
||||
name='show_on_homepage',
|
||||
field=models.BooleanField(default=False, help_text='Display this sub-project in the homepage Projects section.'),
|
||||
),
|
||||
]
|
||||
+12
-91
@@ -30,13 +30,6 @@ INSTALLABLE_PLATFORM_SPECS = (
|
||||
("source_code_url", "Source code", None),
|
||||
)
|
||||
|
||||
DISTRIBUTION_INSTALLABLE = "installable"
|
||||
DISTRIBUTION_PACKAGE = "package"
|
||||
DISTRIBUTION_CHOICES = [
|
||||
(DISTRIBUTION_INSTALLABLE, "Installable application"),
|
||||
(DISTRIBUTION_PACKAGE, "Package (external / non-installable)"),
|
||||
]
|
||||
|
||||
RELEASE_CHANNEL_STABLE = "stable"
|
||||
RELEASE_CHANNEL_BETA = "beta"
|
||||
RELEASE_CHANNEL_RC = "rc"
|
||||
@@ -66,7 +59,6 @@ RELEASE_CHANNEL_LABELS = {
|
||||
RELEASE_CHANNEL_PREVIOUS: "Previous",
|
||||
}
|
||||
|
||||
|
||||
class MainProduct(models.Model):
|
||||
name = models.CharField(max_length=200)
|
||||
slug = models.SlugField(unique=True, blank=True)
|
||||
@@ -76,42 +68,17 @@ class MainProduct(models.Model):
|
||||
max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN
|
||||
)
|
||||
image = models.ImageField(upload_to="products/main/", blank=True, null=True)
|
||||
distribution = models.CharField(
|
||||
max_length=20,
|
||||
choices=DISTRIBUTION_CHOICES,
|
||||
default=DISTRIBUTION_INSTALLABLE,
|
||||
help_text="Only affects how releases appear on the public site.",
|
||||
)
|
||||
package_resource_button_text = models.CharField(
|
||||
max_length=100,
|
||||
default="PyPI",
|
||||
help_text="Label for the package resource button on package releases.",
|
||||
)
|
||||
package_source_button_text = models.CharField(
|
||||
max_length=100,
|
||||
default="Source",
|
||||
help_text="Label for the source code button on package releases.",
|
||||
)
|
||||
downloads_section_link_url = models.URLField(
|
||||
blank=True,
|
||||
help_text="Optional link shown in the top-right corner of the downloads section.",
|
||||
)
|
||||
downloads_section_link_text = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text="Label for the optional downloads section link.",
|
||||
)
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
show_on_homepage = models.BooleanField(default=False, help_text="Display this product in the homepage Products section.")
|
||||
homepage_order = models.PositiveIntegerField(default=0, help_text="Order within the homepage Products section.")
|
||||
show_on_homepage = models.BooleanField(default=False, help_text="Display this project in the homepage Projects section.")
|
||||
homepage_order = models.PositiveIntegerField(default=0, help_text="Order within the homepage Projects section.")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order", "name"]
|
||||
verbose_name = "Main Product"
|
||||
verbose_name_plural = "Main Products"
|
||||
verbose_name = "Main Project"
|
||||
verbose_name_plural = "Main Projects"
|
||||
|
||||
@property
|
||||
def rendered_description(self):
|
||||
@@ -128,18 +95,7 @@ class MainProduct(models.Model):
|
||||
def get_absolute_url(self):
|
||||
return reverse("products:main_product_detail", kwargs={"main_slug": self.slug})
|
||||
|
||||
def get_versions_archive_url(self):
|
||||
return reverse(
|
||||
"products:main_product_versions",
|
||||
kwargs={"main_slug": self.slug},
|
||||
)
|
||||
|
||||
|
||||
class SubProduct(models.Model):
|
||||
DISTRIBUTION_INSTALLABLE = DISTRIBUTION_INSTALLABLE
|
||||
DISTRIBUTION_PACKAGE = DISTRIBUTION_PACKAGE
|
||||
DISTRIBUTION_CHOICES = DISTRIBUTION_CHOICES
|
||||
|
||||
main_product = models.ForeignKey(
|
||||
MainProduct,
|
||||
on_delete=models.CASCADE,
|
||||
@@ -156,41 +112,16 @@ class SubProduct(models.Model):
|
||||
logo = models.ImageField(upload_to="products/sub_logos/", blank=True, null=True, help_text="Small logo shown as a corner badge on homepage product cards.")
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
show_on_homepage = models.BooleanField(default=False, help_text="Display this sub-product in the homepage Products section.")
|
||||
homepage_order = models.PositiveIntegerField(default=0, help_text="Order within the homepage Products section.")
|
||||
distribution = models.CharField(
|
||||
max_length=20,
|
||||
choices=DISTRIBUTION_CHOICES,
|
||||
default=DISTRIBUTION_INSTALLABLE,
|
||||
help_text="Only affects how releases appear on the public site.",
|
||||
)
|
||||
package_resource_button_text = models.CharField(
|
||||
max_length=100,
|
||||
default="PyPI",
|
||||
help_text="Label for the package resource button on package releases.",
|
||||
)
|
||||
package_source_button_text = models.CharField(
|
||||
max_length=100,
|
||||
default="Source",
|
||||
help_text="Label for the source code button on package releases.",
|
||||
)
|
||||
downloads_section_link_url = models.URLField(
|
||||
blank=True,
|
||||
help_text="Optional link shown in the top-right corner of the downloads section.",
|
||||
)
|
||||
downloads_section_link_text = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text="Label for the optional downloads section link.",
|
||||
)
|
||||
show_on_homepage = models.BooleanField(default=False, help_text="Display this sub-project in the homepage Projects section.")
|
||||
homepage_order = models.PositiveIntegerField(default=0, help_text="Order within the homepage Projects section.")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order", "name"]
|
||||
unique_together = [["main_product", "slug"]]
|
||||
verbose_name = "Sub Product"
|
||||
verbose_name_plural = "Sub Products"
|
||||
verbose_name = "Sub-project"
|
||||
verbose_name_plural = "Sub-projects"
|
||||
|
||||
@property
|
||||
def rendered_description(self):
|
||||
@@ -213,16 +144,6 @@ class SubProduct(models.Model):
|
||||
},
|
||||
)
|
||||
|
||||
def get_versions_archive_url(self):
|
||||
return reverse(
|
||||
"products:sub_product_versions",
|
||||
kwargs={
|
||||
"main_slug": self.main_product.slug,
|
||||
"sub_slug": self.slug,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Article(models.Model):
|
||||
main_product = models.ForeignKey(
|
||||
MainProduct,
|
||||
@@ -287,7 +208,7 @@ class Article(models.Model):
|
||||
has_sub = self.sub_product_id is not None
|
||||
if has_main == has_sub:
|
||||
raise ValidationError(
|
||||
"An article must belong to exactly one main product or sub-product."
|
||||
"An article must belong to exactly one main project or sub-project."
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
@@ -572,8 +493,8 @@ class ProductVideo(VideoBlockMixin, models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ["order", "pk"]
|
||||
verbose_name = "Product Video"
|
||||
verbose_name_plural = "Product Videos"
|
||||
verbose_name = "Project Video"
|
||||
verbose_name_plural = "Project Videos"
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=(
|
||||
@@ -590,7 +511,7 @@ class ProductVideo(VideoBlockMixin, models.Model):
|
||||
has_sub = self.sub_product_id is not None
|
||||
if has_main == has_sub:
|
||||
raise ValidationError(
|
||||
"A product video must belong to exactly one main product or sub-product."
|
||||
"A project video must belong to exactly one main project or sub-project."
|
||||
)
|
||||
self.clean_video_fields(require=True)
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct, SubProductVersion
|
||||
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct
|
||||
|
||||
|
||||
class ProductViewsSetup(TestCase):
|
||||
@@ -153,198 +149,8 @@ class SubProductDetailViewTest(ProductViewsSetup):
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class SubProductOlderVersionsViewTest(ProductViewsSetup):
|
||||
def test_versions_returns_200(self):
|
||||
from apps.products.models import SubProductVersion
|
||||
|
||||
SubProductVersion.objects.create(
|
||||
sub_product=self.sub_product,
|
||||
version="9.9",
|
||||
is_featured=True,
|
||||
release_channel="stable",
|
||||
windows_download_url="https://example.com/w",
|
||||
is_active=True,
|
||||
)
|
||||
SubProductVersion.objects.create(
|
||||
sub_product=self.sub_product,
|
||||
version="9.8",
|
||||
is_featured=False,
|
||||
windows_download_url="https://example.com/w2",
|
||||
is_active=True,
|
||||
)
|
||||
url = reverse(
|
||||
"products:sub_product_versions",
|
||||
kwargs={"main_slug": "com2care", "sub_slug": "image-processing"},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "products/versions_archive.html")
|
||||
|
||||
def test_main_versions_returns_200(self):
|
||||
from apps.products.models import SubProductVersion
|
||||
|
||||
SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="9.9",
|
||||
is_featured=True,
|
||||
release_channel="stable",
|
||||
windows_download_url="https://example.com/win.exe",
|
||||
is_active=True,
|
||||
)
|
||||
SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="9.8",
|
||||
is_active=True,
|
||||
)
|
||||
url = reverse(
|
||||
"products:main_product_versions",
|
||||
kwargs={"main_slug": "com2care"},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "products/versions_archive.html")
|
||||
|
||||
def test_main_detail_releases_in_context(self):
|
||||
from apps.products.models import SubProductVersion
|
||||
|
||||
SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="1.0",
|
||||
windows_download_url="https://example.com/win.exe",
|
||||
is_active=True,
|
||||
)
|
||||
url = reverse("products:main_product_detail", kwargs={"main_slug": "com2care"})
|
||||
response = self.client.get(url)
|
||||
self.assertTrue(response.context["show_releases_section"])
|
||||
|
||||
def test_installable_shows_release_channel_badges(self):
|
||||
from apps.products.models import RELEASE_CHANNEL_BETA, RELEASE_CHANNEL_PREVIOUS, SubProductVersion
|
||||
|
||||
SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="2.0",
|
||||
release_channel=RELEASE_CHANNEL_BETA,
|
||||
is_featured=True,
|
||||
windows_download_url="https://example.com/beta.exe",
|
||||
is_active=True,
|
||||
)
|
||||
SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="1.9",
|
||||
release_channel=RELEASE_CHANNEL_PREVIOUS,
|
||||
show_on_product_page=True,
|
||||
windows_download_url="https://example.com/prev.exe",
|
||||
is_active=True,
|
||||
)
|
||||
url = reverse("products:main_product_detail", kwargs={"main_slug": "com2care"})
|
||||
response = self.client.get(url)
|
||||
self.assertContains(response, "Beta")
|
||||
self.assertContains(response, "Previous")
|
||||
self.assertEqual(len(response.context["inline_download_channels"]), 1)
|
||||
|
||||
def test_custom_channel_label_overrides_preset(self):
|
||||
from apps.products.models import RELEASE_CHANNEL_STABLE, SubProductVersion
|
||||
|
||||
SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="3.0",
|
||||
release_channel=RELEASE_CHANNEL_STABLE,
|
||||
channel_label="Early Access",
|
||||
is_featured=True,
|
||||
windows_download_url="https://example.com/ea.exe",
|
||||
is_active=True,
|
||||
)
|
||||
url = reverse("products:main_product_detail", kwargs={"main_slug": "com2care"})
|
||||
response = self.client.get(url)
|
||||
self.assertContains(response, "Early Access")
|
||||
self.assertNotContains(response, ">Stable<")
|
||||
|
||||
|
||||
@override_settings(MEDIA_ROOT=settings.BASE_DIR / "test_media_releases")
|
||||
class ReleaseAssetDownloadViewTest(ProductViewsSetup):
|
||||
def test_uploaded_file_download(self):
|
||||
release_file = SimpleUploadedFile("setup.exe", b"binary-payload")
|
||||
version = SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="2.0",
|
||||
windows_download_file=release_file,
|
||||
is_active=True,
|
||||
)
|
||||
url = reverse(
|
||||
"products:release_asset_download",
|
||||
kwargs={"version_id": version.pk, "asset": "windows"},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(b"".join(response.streaming_content), b"binary-payload")
|
||||
self.assertIn("attachment", response["Content-Disposition"])
|
||||
self.assertIn("setup.exe", response["Content-Disposition"])
|
||||
|
||||
def test_external_url_blocks_file_download(self):
|
||||
version = SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="2.1",
|
||||
windows_download_url="https://example.com/win.exe",
|
||||
windows_download_file=SimpleUploadedFile("local.exe", b"x"),
|
||||
is_active=True,
|
||||
)
|
||||
url = reverse(
|
||||
"products:release_asset_download",
|
||||
kwargs={"version_id": version.pk, "asset": "windows"},
|
||||
)
|
||||
self.assertEqual(self.client.get(url).status_code, 404)
|
||||
|
||||
def test_product_page_uses_file_when_no_url(self):
|
||||
version = SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="3.0",
|
||||
windows_download_file=SimpleUploadedFile("win.tar.gz", b"gz"),
|
||||
is_active=True,
|
||||
)
|
||||
detail_url = reverse("products:main_product_detail", kwargs={"main_slug": "com2care"})
|
||||
response = self.client.get(detail_url)
|
||||
download_url = reverse(
|
||||
"products:release_asset_download",
|
||||
kwargs={"version_id": version.pk, "asset": "windows"},
|
||||
)
|
||||
self.assertContains(response, download_url)
|
||||
|
||||
def test_inactive_version_returns_404(self):
|
||||
version = SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="0.1",
|
||||
windows_download_file=SimpleUploadedFile("a.exe", b"a"),
|
||||
is_active=False,
|
||||
)
|
||||
url = reverse(
|
||||
"products:release_asset_download",
|
||||
kwargs={"version_id": version.pk, "asset": "windows"},
|
||||
)
|
||||
self.assertEqual(self.client.get(url).status_code, 404)
|
||||
|
||||
def test_clearing_file_removes_from_disk(self):
|
||||
version = SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="4.0",
|
||||
windows_download_file=SimpleUploadedFile("remove-me.exe", b"data"),
|
||||
is_active=True,
|
||||
)
|
||||
stored_path = version.windows_download_file.path
|
||||
self.assertTrue(stored_path)
|
||||
version.windows_download_file = ""
|
||||
version.save()
|
||||
version.refresh_from_db()
|
||||
self.assertFalse(version.windows_download_file)
|
||||
self.assertEqual(version.windows_download_filename, "")
|
||||
self.assertFalse(os.path.exists(stored_path))
|
||||
|
||||
def test_deleting_version_removes_files_from_disk(self):
|
||||
version = SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="5.0",
|
||||
windows_download_file=SimpleUploadedFile("gone.exe", b"data"),
|
||||
is_active=True,
|
||||
)
|
||||
stored_path = version.windows_download_file.path
|
||||
version.delete()
|
||||
self.assertFalse(os.path.exists(stored_path))
|
||||
class RemovedDistributionViewsTest(ProductViewsSetup):
|
||||
def test_project_pages_do_not_expose_downloads_or_versions(self):
|
||||
response = self.client.get(self.main_product.get_absolute_url())
|
||||
self.assertNotContains(response, "Downloads")
|
||||
self.assertNotContains(response, "Older releases")
|
||||
|
||||
@@ -6,11 +6,6 @@ app_name = "products"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.ProductOverviewView.as_view(), name="overview"),
|
||||
path(
|
||||
"releases/<int:version_id>/<slug:asset>/download/",
|
||||
views.ReleaseAssetDownloadView.as_view(),
|
||||
name="release_asset_download",
|
||||
),
|
||||
path(
|
||||
"<slug:main_slug>/videos/",
|
||||
views.ProductVideoArchiveView.as_view(),
|
||||
@@ -26,16 +21,6 @@ urlpatterns = [
|
||||
views.MainProductDetailView.as_view(),
|
||||
name="main_product_detail",
|
||||
),
|
||||
path(
|
||||
"<slug:main_slug>/versions/",
|
||||
views.MainProductOlderVersionsView.as_view(),
|
||||
name="main_product_versions",
|
||||
),
|
||||
path(
|
||||
"<slug:main_slug>/<slug:sub_slug>/versions/",
|
||||
views.SubProductOlderVersionsView.as_view(),
|
||||
name="sub_product_versions",
|
||||
),
|
||||
path(
|
||||
"<slug:main_slug>/<slug:sub_slug>/",
|
||||
views.SubProductDetailView.as_view(),
|
||||
|
||||
+2
-95
@@ -1,14 +1,9 @@
|
||||
import mimetypes
|
||||
|
||||
from django.http import FileResponse, Http404
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.views import View
|
||||
from django.views.generic import DetailView, ListView, TemplateView
|
||||
|
||||
from .models import Article, ArticleCitation, ArticleSection, MainProduct, ProductVideo, SubProduct, SubProductVersion
|
||||
from .release_assets import FILE_FIELD_BY_ASSET_KEY, RELEASE_ASSET_KEYS, URL_FIELD_BY_ASSET_KEY
|
||||
from .release_context import build_archive_context, build_release_context
|
||||
from .models import MainProduct, ProductVideo, SubProduct
|
||||
|
||||
VIDEO_PREVIEW_LIMIT = 3
|
||||
VIDEO_ARCHIVE_PAGE_SIZE = 6
|
||||
@@ -23,31 +18,6 @@ def _product_video_preview(queryset, archive_url):
|
||||
}
|
||||
|
||||
|
||||
class ReleaseAssetDownloadView(View):
|
||||
def get(self, request, version_id, asset):
|
||||
if asset not in RELEASE_ASSET_KEYS:
|
||||
raise Http404
|
||||
version = get_object_or_404(SubProductVersion, pk=version_id, is_active=True)
|
||||
parent = version.sub_product or version.main_product
|
||||
if parent is None or not parent.is_active:
|
||||
raise Http404
|
||||
url_field = URL_FIELD_BY_ASSET_KEY[asset]
|
||||
if (getattr(version, url_field) or "").strip():
|
||||
raise Http404
|
||||
file_field = FILE_FIELD_BY_ASSET_KEY[asset]
|
||||
release_file = getattr(version, file_field)
|
||||
if not release_file:
|
||||
raise Http404
|
||||
filename = version.release_download_filename(asset)
|
||||
content_type, _ = mimetypes.guess_type(filename)
|
||||
return FileResponse(
|
||||
release_file.open("rb"),
|
||||
as_attachment=True,
|
||||
filename=filename,
|
||||
content_type=content_type or "application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
class ProductOverviewView(ListView):
|
||||
model = MainProduct
|
||||
template_name = "products/overview.html"
|
||||
@@ -81,35 +51,6 @@ class MainProductDetailView(DetailView):
|
||||
),
|
||||
)
|
||||
)
|
||||
release_context = build_release_context(
|
||||
self.object.distribution,
|
||||
self.object.versions.filter(is_active=True),
|
||||
self.object,
|
||||
)
|
||||
release_context["versions_archive_url"] = self.object.get_versions_archive_url()
|
||||
context.update(release_context)
|
||||
return context
|
||||
|
||||
|
||||
class MainProductOlderVersionsView(TemplateView):
|
||||
template_name = "products/versions_archive.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
main_product = get_object_or_404(
|
||||
MainProduct,
|
||||
slug=self.kwargs["main_slug"],
|
||||
is_active=True,
|
||||
)
|
||||
active_versions = main_product.versions.filter(is_active=True)
|
||||
context["main_product"] = main_product
|
||||
context["sub_product"] = None
|
||||
context["product_detail_url"] = main_product.get_absolute_url()
|
||||
context.update(
|
||||
build_archive_context(
|
||||
main_product.distribution, active_versions, main_product
|
||||
)
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
@@ -152,12 +93,6 @@ class SubProductDetailView(TemplateView):
|
||||
.exclude(pk=sub_product.pk)
|
||||
.order_by("order", "name")
|
||||
)
|
||||
active_versions = sub_product.versions.filter(is_active=True)
|
||||
release_context = build_release_context(
|
||||
sub_product.distribution, active_versions, sub_product
|
||||
)
|
||||
release_context["versions_archive_url"] = sub_product.get_versions_archive_url()
|
||||
context.update(release_context)
|
||||
return context
|
||||
|
||||
|
||||
@@ -203,31 +138,3 @@ class ProductVideoArchiveView(ListView):
|
||||
}
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class SubProductOlderVersionsView(TemplateView):
|
||||
template_name = "products/versions_archive.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
main_product = get_object_or_404(
|
||||
MainProduct,
|
||||
slug=self.kwargs["main_slug"],
|
||||
is_active=True,
|
||||
)
|
||||
sub_product = get_object_or_404(
|
||||
SubProduct,
|
||||
slug=self.kwargs["sub_slug"],
|
||||
main_product=main_product,
|
||||
is_active=True,
|
||||
)
|
||||
active_versions = sub_product.versions.filter(is_active=True)
|
||||
context["main_product"] = main_product
|
||||
context["sub_product"] = sub_product
|
||||
context["product_detail_url"] = sub_product.get_absolute_url()
|
||||
context.update(
|
||||
build_archive_context(
|
||||
sub_product.distribution, active_versions, sub_product
|
||||
)
|
||||
)
|
||||
return context
|
||||
|
||||
Reference in New Issue
Block a user