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
+1 -1
View File
@@ -11,7 +11,7 @@ admin.site.index_title = "Welcome to Communication to Care Administration"
urlpatterns = [
path("admin/", admin.site.urls),
path("products/", include("apps.products.urls", namespace="products")),
path("projects/", include("apps.products.urls", namespace="products")),
path("", include("apps.pages.urls", namespace="pages")),
]
+3 -1
View File
@@ -29,7 +29,9 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-com2care_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-com2care_local_password}
POSTGRES_HOST: db
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
# Containers always reach PostgreSQL on its internal port. The host-side
# development mapping (5433 by default) does not apply inside Compose.
POSTGRES_PORT: 5432
DJANGO_SUPERUSER_ENABLED: ${DJANGO_SUPERUSER_ENABLED:-True}
DJANGO_SUPERUSER_USERNAME: ${DJANGO_SUPERUSER_USERNAME:-admin}
DJANGO_SUPERUSER_EMAIL: ${DJANGO_SUPERUSER_EMAIL:-admin@com2care.com}
+22
View File
@@ -95,6 +95,28 @@ body::before {
z-index: 0;
}
/* Blog */
.blog-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
gap: var(--spacing-md);
}
.blog-card { overflow: hidden; }
.blog-card-image { display: block; aspect-ratio: 16 / 9; overflow: hidden; }
.blog-card-image img { width: 100%; height: 100%; object-fit: cover; transition: var(--transition-smooth); }
.blog-card:hover .blog-card-image img { transform: scale(1.04); }
.blog-card-body { padding: var(--spacing-md); }
.blog-meta { color: var(--text-muted); font-size: .86rem; margin-bottom: .7rem; }
.blog-card-title { font-size: 1.35rem; margin-bottom: .7rem; }
.blog-card-title a { color: var(--text-primary); }
.blog-card-excerpt { color: var(--text-secondary); margin-bottom: var(--spacing-sm); }
.blog-detail-header { max-width: 900px; }
.blog-article-wrap { max-width: 900px; }
.blog-cover { width: 100%; max-height: 520px; object-fit: cover; border-radius: var(--radius-lg); margin-bottom: var(--spacing-md); }
.blog-article { padding: clamp(1.5rem, 4vw, 3rem); }
.pagination { display: flex; align-items: center; justify-content: center; gap: var(--spacing-sm); margin-top: var(--spacing-xl); color: var(--text-secondary); }
#main-content {
position: relative;
z-index: 1;
-31
View File
@@ -5,9 +5,6 @@ const SELECTORS = {
navbarToggle: '#navbarToggle',
navbarMenu: '#navbarMenu',
productsNavItem: '#productsNavItem',
faqItems: '.faq-item',
faqQuestion: '.faq-question',
faqAnswer: '.faq-answer',
fadeInElements: '.fade-in',
citationCopy: '.citation-copy',
};
@@ -192,33 +189,6 @@ function initMegaMenu() {
});
}
function initFAQAccordion() {
const faqItems = document.querySelectorAll(SELECTORS.faqItems);
faqItems.forEach((item) => {
const question = item.querySelector(SELECTORS.faqQuestion);
const answer = item.querySelector(SELECTORS.faqAnswer);
if (!question || !answer) return;
question.addEventListener('click', () => {
const isOpen = item.classList.contains('open');
faqItems.forEach((other) => {
if (other !== item) {
other.classList.remove('open');
const otherQuestion = other.querySelector(SELECTORS.faqQuestion);
const otherAnswer = other.querySelector(SELECTORS.faqAnswer);
if (otherQuestion) otherQuestion.setAttribute('aria-expanded', 'false');
if (otherAnswer) otherAnswer.hidden = true;
}
});
item.classList.toggle('open', !isOpen);
question.setAttribute('aria-expanded', String(!isOpen));
answer.hidden = isOpen;
});
});
}
function initIntersectionObserver() {
const elements = document.querySelectorAll(SELECTORS.fadeInElements);
if (!elements.length) return;
@@ -329,7 +299,6 @@ function init() {
initNavbarScroll();
initMobileMenu();
initMegaMenu();
initFAQAccordion();
initIntersectionObserver();
initReadMore();
initCitationCopy();
+4 -3
View File
@@ -4,12 +4,12 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="{% block meta_description %}Communication to Care — Advanced Medical Imaging & Radiomics Solutions{% endblock %}" />
<meta property="og:site_name" content="Communication to Care" />
<meta name="description" content="{% block meta_description %}{{ site_branding.meta_description }}{% endblock %}" />
<meta property="og:site_name" content="{{ site_branding.site_name }}" />
<meta property="og:type" content="website" />
<meta property="og:image" content="https://com2care.com{% static 'images/com2care-care-team.jpg' %}" />
<link rel="canonical" href="https://com2care.com{{ request.path }}" />
<title>{% block title %}Communication to Care{% endblock %} | Communication to Care</title>
<title>{% block title %}{{ site_branding.site_name }}{% endblock %} | {{ site_branding.site_name }}</title>
{% if site_branding.website_icon %}
<link rel="icon" href="{{ site_branding.website_icon.url }}" />
<link rel="apple-touch-icon" href="{{ site_branding.website_icon.url }}" />
@@ -21,6 +21,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="{% static 'css/main.css' %}" />
<style>:root { {{ site_branding.theme_style }} }</style>
{% block extra_css %}{% endblock %}
</head>
<body>
+1 -1
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block title %}About{% endblock %}
{% block meta_description %}Learn how Communication to Care helps multidisciplinary teams build clearer, reproducible medical-imaging research workflows.{% endblock %}
{% block meta_description %}Learn how {{ site_branding.site_name }} helps multidisciplinary teams build clearer, reproducible medical-imaging research workflows.{% endblock %}
{% block content %}
{% include "partials/_page_sections.html" with sections=about_sections %}
+32
View File
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}{{ post.title }}{% endblock %}
{% block meta_description %}{{ post.excerpt|default:post.title }}{% endblock %}
{% block content %}
<article class="blog-detail">
<header class="page-hero blog-detail-hero">
<div class="page-hero-blobs" aria-hidden="true"><div class="blob blob--1"></div><div class="blob blob--2"></div></div>
<div class="container blog-detail-header">
<nav class="breadcrumb" aria-label="Breadcrumb">
<ol class="breadcrumb-list" role="list">
<li><a href="{% url 'pages:home' %}">Home</a></li>
<li aria-hidden="true" class="breadcrumb-sep">›</li>
<li><a href="{% url 'pages:blog_list' %}">Blog</a></li>
<li aria-hidden="true" class="breadcrumb-sep">›</li>
<li aria-current="page">{{ post.title }}</li>
</ol>
</nav>
<p class="blog-meta">{{ post.published_at|date:"F j, Y" }}{% if post.author_name %} · {{ post.author_name }}{% endif %}</p>
<h1 class="page-hero-title">{{ post.title }}</h1>
{% if post.excerpt %}<p class="page-hero-subtitle">{{ post.excerpt }}</p>{% endif %}
</div>
</header>
<div class="section">
<div class="container blog-article-wrap">
{% if post.cover_image %}<img class="blog-cover" src="{{ post.cover_image.url }}" alt="{{ post.cover_image_alt|default:post.title }}" />{% endif %}
<div class="blog-article rich-content glass-card">{{ post.rendered_content }}</div>
</div>
</div>
</article>
{% endblock %}
+47
View File
@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}Blog{% endblock %}
{% block meta_description %}News, research notes, and community updates from {{ site_branding.site_name }}.{% endblock %}
{% block content %}
<section class="page-hero" aria-labelledby="blog-heading">
<div class="page-hero-blobs" aria-hidden="true">
<div class="blob blob--1"></div>
<div class="blob blob--2"></div>
</div>
<div class="container">
<div class="page-hero-content">
<div class="section-badge">Insights and Updates</div>
<h1 class="page-hero-title" id="blog-heading">Blog</h1>
<p class="page-hero-subtitle">Research stories, project updates, and practical ideas from our community.</p>
</div>
</div>
</section>
<section class="section" aria-label="Blog posts">
<div class="container">
{% if posts %}
<div class="blog-grid">
{% for post in posts %}
<article class="blog-card glass-card fade-in">
{% if post.cover_image %}
<a href="{{ post.get_absolute_url }}" class="blog-card-image">
<img src="{{ post.cover_image.url }}" alt="{{ post.cover_image_alt|default:post.title }}" loading="lazy" />
</a>
{% endif %}
<div class="blog-card-body">
<p class="blog-meta">{{ post.published_at|date:"F j, Y" }}{% if post.author_name %} · {{ post.author_name }}{% endif %}</p>
<h2 class="blog-card-title"><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h2>
{% if post.excerpt %}<p class="blog-card-excerpt">{{ post.excerpt }}</p>{% endif %}
<a href="{{ post.get_absolute_url }}" class="link-arrow">Read article</a>
</div>
</article>
{% endfor %}
</div>
{% include "partials/_pagination.html" with page_obj=page_obj %}
{% else %}
<div class="empty-state-block glass-card"><p>No blog posts have been published yet.</p></div>
{% endif %}
</div>
</section>
{% endblock %}
+6 -6
View File
@@ -1,8 +1,8 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Contact{% endblock %}
{% block meta_description %}Contact Communication to Care for com2care product support, research evaluation, and general inquiries.{% endblock %}
{% block title %}Become a Member{% endblock %}
{% block meta_description %}Become a member of {{ site_branding.site_name }} and connect with the com2care research community.{% endblock %}
{% block content %}
@@ -13,9 +13,9 @@
</div>
<div class="container">
<div class="page-hero-content">
<div class="section-badge">Get in Touch</div>
<h1 class="page-hero-title" id="contact-heading">Contact Us</h1>
<p class="page-hero-subtitle">Have a product, evaluation, or research question? Send the com2care team a message below.</p>
<div class="section-badge">Join the Community</div>
<h1 class="page-hero-title" id="contact-heading">Become a Member</h1>
<p class="page-hero-subtitle">Tell us how you would like to contribute, collaborate, or take part in the com2care research community.</p>
</div>
</div>
</section>
@@ -27,7 +27,7 @@
<div class="contact-layout{% if not site_contact.has_contact_sidebar %} contact-layout--full{% endif %}">
<div class="contact-form-wrap glass-card fade-in">
<h2 class="contact-form-title" id="contact-form-heading">Send a Message</h2>
<h2 class="contact-form-title" id="contact-form-heading">Membership Request</h2>
<form id="contactForm" novalidate action="{% url 'pages:contact' %}" method="post" enctype="multipart/form-data">
{% csrf_token %}
-67
View File
@@ -1,67 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% block title %}FAQ{% endblock %}
{% block meta_description %}Frequently asked questions about Communication to Care licensing, research use, releases, support, and responsible deployment.{% endblock %}
{% block content %}
<section class="page-hero" aria-labelledby="faq-heading">
<div class="page-hero-blobs" aria-hidden="true">
<div class="blob blob--1"></div>
<div class="blob blob--2"></div>
</div>
<div class="container">
<div class="page-hero-content">
<div class="section-badge">Help</div>
<h1 class="page-hero-title" id="faq-heading">FAQ</h1>
<p class="page-hero-subtitle">Frequently Asked Questions</p>
</div>
</div>
</section>
{% include "partials/_page_videos.html" with videos=faq_videos %}
<section class="section" aria-labelledby="faq-list-heading">
<div class="container">
<h2 class="sr-only" id="faq-list-heading">FAQ List</h2>
{% if faq_entries %}
<div class="faq-list">
{% for entry in faq_entries %}
<div class="faq-item glass-card fade-in">
<button
class="faq-question"
aria-expanded="false"
aria-controls="faq-answer-{{ entry.pk }}"
id="faq-question-{{ entry.pk }}"
>
{{ entry.question }}
<svg class="faq-icon" width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M5 8L10 13L15 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div
class="faq-answer"
id="faq-answer-{{ entry.pk }}"
role="region"
aria-labelledby="faq-question-{{ entry.pk }}"
hidden
>
<div class="rich-content">{{ entry.rendered_answer }}</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty-state">No FAQ entries available yet.</p>
{% endif %}
<div class="faq-contact glass-card fade-in">
<h2>Still Have Questions?</h2>
<p>Can't find the answer you're looking for? Get in touch with our support team.</p>
<a href="{% url 'pages:contact' %}" class="btn-primary">Contact Support</a>
</div>
</div>
</section>
{% endblock %}
+2 -2
View File
@@ -2,7 +2,7 @@
{% load static %}
{% block title %}Home{% endblock %}
{% block meta_description %}Communication to Care — A Powerful Workflow Generator for Standardized Radiomics Analysis and Medical Image Visualization.{% endblock %}
{% block meta_description %}{{ site_branding.meta_description }}{% endblock %}
{% block content %}
@@ -38,7 +38,7 @@
<div class="hero-app-preview fade-in">
<div class="hero-app-preview-glow" aria-hidden="true"></div>
{% if site_branding.hero_logo %}
<img src="{{ site_branding.hero_logo.url }}" alt="{{ site_branding.hero_logo_alt|default:'Communication to Care' }}" loading="eager" />
<img src="{{ site_branding.hero_logo.url }}" alt="{{ site_branding.hero_logo_alt|default:site_branding.site_name }}" loading="eager" />
{% elif hero and hero.image %}
<img src="{{ hero.image.url }}" alt="{{ hero.image_alt }}" loading="eager" />
{% else %}
+11 -11
View File
@@ -8,12 +8,12 @@
<div class="footer-grid">
<div class="footer-brand-col">
<a href="{% url 'pages:home' %}" class="footer-logo" aria-label="Communication to Care home">
<a href="{% url 'pages:home' %}" class="footer-logo" aria-label="{{ site_branding.site_name }} home">
{% include "partials/_brand_icon.html" with placement="footer" %}
<span class="brand-name">Communication to Care</span>
<span class="brand-name">{{ site_branding.site_name }}</span>
</a>
<p class="footer-tagline">
Turning complex medical-imaging evidence into clearer, reproducible research conversations.
{{ site_branding.tagline }}
</p>
{% include "partials/_contact_discord.html" with button_class="btn-ghost btn-sm" %}
</div>
@@ -22,22 +22,22 @@
<h3 class="footer-heading">Navigation</h3>
<ul class="footer-links" role="list">
<li><a href="{% url 'pages:home' %}">Home</a></li>
<li><a href="{% url 'pages:about' %}">What is Communication to Care</a></li>
<li><a href="{% url 'products:overview' %}">Products</a></li>
<li><a href="{% url 'pages:faq' %}">FAQ</a></li>
<li><a href="{% url 'pages:contact' %}">Contact</a></li>
<li><a href="{% url 'pages:about' %}">About {{ site_branding.site_name }}</a></li>
<li><a href="{% url 'products:overview' %}">Projects</a></li>
<li><a href="{% url 'pages:blog_list' %}">Blog</a></li>
<li><a href="{% url 'pages:contact' %}">Become a Member</a></li>
</ul>
</div>
{% if footer_main_products %}
<div class="footer-links-col">
<h3 class="footer-heading">Products</h3>
<h3 class="footer-heading">Projects</h3>
<ul class="footer-links" role="list">
{% for product in footer_main_products %}
<li><a href="{{ product.get_absolute_url }}">{{ product.name }}</a></li>
{% endfor %}
{% if footer_main_products_has_more %}
<li><a href="{% url 'products:overview' %}">More products</a></li>
<li><a href="{% url 'products:overview' %}">More projects</a></li>
{% endif %}
</ul>
</div>
@@ -45,7 +45,7 @@
{% if site_contact.has_footer_contact %}
<div class="footer-contact-col">
<h3 class="footer-heading">Contact</h3>
<h3 class="footer-heading">Membership</h3>
{% include "partials/_contact_office.html" with address_class="footer-address" %}
{% include "partials/_contact_email.html" with link_class="footer-email" %}
</div>
@@ -55,7 +55,7 @@
<div class="footer-bottom">
<p class="footer-copy">
&copy; {% now "Y" %} Communication to Care. All rights reserved.
&copy; {% now "Y" %} {{ site_branding.site_name }}. All rights reserved.
</p>
<p class="footer-credit">
Research collaboration at <a href="https://com2care.com">com2care.com</a>
+11 -11
View File
@@ -2,9 +2,9 @@
<nav class="navbar" id="navbar" role="navigation" aria-label="Main navigation">
<div class="navbar-container">
<a href="{% url 'pages:home' %}" class="navbar-brand" aria-label="Communication to Care home">
<a href="{% url 'pages:home' %}" class="navbar-brand" aria-label="{{ site_branding.site_name }} home">
{% include "partials/_brand_icon.html" with placement="navbar" %}
<span class="brand-name">com2care</span>
<span class="brand-name">{{ site_branding.short_name }}</span>
</a>
<button class="navbar-toggle" id="navbarToggle" aria-expanded="false" aria-controls="navbarMenu" aria-label="Toggle navigation">
@@ -25,14 +25,14 @@
{% if nav_main_products %}
<li class="nav-item has-megamenu" id="productsNavItem">
<a href="{% url 'products:overview' %}" class="nav-link {% if 'products' in request.resolver_match.namespace %}active{% endif %}" aria-haspopup="true" aria-expanded="false" id="productsTrigger">
Products
Projects
<svg class="nav-arrow" width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M2 4L6 8L10 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</a>
<div class="megamenu" role="menu" aria-labelledby="productsTrigger">
<div class="megamenu-inner">
<div class="megamenu-products-scroll" tabindex="0" aria-label="Product list">
<div class="megamenu-products-scroll" tabindex="0" aria-label="Project list">
<ul class="megamenu-products" role="list">
{% for product in nav_main_products %}
<li class="megamenu-product-item{% if product.sub_products.all %} has-subproducts{% endif %}">
@@ -71,6 +71,12 @@
</a>
</li>
<li class="nav-item">
<a href="{% url 'pages:blog_list' %}" class="nav-link {% if request.resolver_match.url_name == 'blog_list' or request.resolver_match.url_name == 'blog_detail' %}active{% endif %}">
Blog
</a>
</li>
{% for page in nav_custom_pages %}
<li class="nav-item">
<a href="{% url 'pages:custom_page' slug=page.slug %}" class="nav-link {% if request.resolver_match.url_name == 'custom_page' and request.resolver_match.kwargs.slug == page.slug %}active{% endif %}">
@@ -79,15 +85,9 @@
</li>
{% endfor %}
<li class="nav-item">
<a href="{% url 'pages:faq' %}" class="nav-link {% if request.resolver_match.url_name == 'faq' %}active{% endif %}">
FAQ
</a>
</li>
<li class="nav-item">
<a href="{% url 'pages:contact' %}" class="nav-link nav-link--cta {% if request.resolver_match.url_name == 'contact' %}active{% endif %}">
Contact
Become a Member
</a>
</li>
+8 -62
View File
@@ -163,7 +163,7 @@
<div class="section-header">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 class="section-title" id="features-heading-{{ section.pk }}">{{ section.title }}</h2>{% endif %}
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
{% if section.description %}<div class="rich-content section-desc">{{ section.rendered_description }}</div>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
</div>
<div class="features-grid">
{% for item in section.items.all %}
@@ -183,7 +183,7 @@
<div class="section-header">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 class="section-title" id="screenshots-heading-{{ section.pk }}">{{ section.title }}</h2>{% endif %}
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
{% if section.description %}<div class="rich-content section-desc">{{ section.rendered_description }}</div>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
</div>
<div class="screenshots-grid">
{% for item in section.items.all %}
@@ -217,7 +217,7 @@
<div class="section-header">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 class="section-title" id="products-heading-{{ section.pk }}">{{ section.title }}</h2>{% endif %}
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
{% if section.description %}<div class="rich-content section-desc">{{ section.rendered_description }}</div>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
</div>
<div class="products-grid">
{% for product in homepage_products %}
@@ -251,7 +251,7 @@
<div class="section-header">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 class="section-title" id="products-catalog-heading-{{ section.pk }}">{{ section.title }}</h2>{% endif %}
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
{% if section.description %}<div class="rich-content section-desc">{{ section.rendered_description }}</div>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
</div>
<div class="products-overview-grid">
{% for product in homepage_products_catalog %}
@@ -264,7 +264,7 @@
<div class="product-overview-body">
<h3 class="product-overview-title">{{ product.name }}</h3>
<p class="product-overview-short">{{ product.short_description }}</p>
<p class="product-overview-desc" data-readmore="110">{{ product.description }}</p>
<div class="product-overview-desc rich-content" data-readmore="110">{{ product.rendered_description }}</div>
<a href="{{ product.get_absolute_url }}" class="btn-primary">Explore {{ product.name }}</a>
</div>
{% if product.sub_products.all %}
@@ -297,7 +297,7 @@
<div class="section-header">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 class="section-title" id="problems-heading-{{ section.pk }}">{{ section.title }}</h2>{% endif %}
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
{% if section.description %}<div class="rich-content section-desc">{{ section.rendered_description }}</div>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
</div>
<div class="problems-grid">
{% for item in section.items.all %}
@@ -317,7 +317,7 @@
<div class="section-header">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 class="section-title" id="supporters-heading-{{ section.pk }}">{{ section.title }}</h2>{% endif %}
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
{% if section.description %}<div class="rich-content section-desc">{{ section.rendered_description }}</div>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
</div>
<div class="supporters-grid">
{% for item in section.items.all %}
@@ -344,7 +344,7 @@
<div class="about-strip-content">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 class="section-title" id="about-strip-heading-{{ section.pk }}">{{ section.title }}</h2>{% endif %}
{% if section.description %}<p class="about-strip-text">{{ section.description }}</p>{% elif section.content %}<p class="about-strip-text">{{ section.rendered_content }}</p>{% endif %}
{% if section.description %}<div class="about-strip-text rich-content">{{ section.rendered_description }}</div>{% elif section.content %}<div class="about-strip-text rich-content">{{ section.rendered_content }}</div>{% endif %}
{% if section.link_text and section.link_url %}
<a href="{{ section.link_url }}" class="btn-primary">{{ section.link_text }}</a>
{% endif %}
@@ -357,60 +357,6 @@
</div>
</section>
{% elif section.section_type == "faq" %}
<section class="section" aria-labelledby="faq-section-{{ section.pk }}-heading">
<div class="container">
{% if section.badge or section.title %}
<div class="section-header">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 class="section-title" id="faq-section-{{ section.pk }}-heading">{{ section.title }}</h2>{% endif %}
</div>
{% endif %}
{% with items=section.items.all %}
{% if items %}
<div class="faq-list">
{% for item in items %}
{% if item.url %}
{% include "partials/_linked_card_open.html" with url=item.url card_class="faq-item faq-item--link" %}
<div class="faq-question">
{{ item.title }}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 8H13M13 8L9 4M13 8L9 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
{% if item.content %}<div class="rich-content faq-link-preview">{{ item.rendered_content }}</div>{% endif %}
{% include "partials/_linked_card_close.html" with url=item.url %}
{% else %}
<div class="faq-item glass-card fade-in">
<button
class="faq-question"
aria-expanded="false"
aria-controls="faq-answer-{{ section.pk }}-{{ item.pk }}"
id="faq-question-{{ section.pk }}-{{ item.pk }}"
>
{{ item.title }}
<svg class="faq-icon" width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M5 8L10 13L15 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div
class="faq-answer"
id="faq-answer-{{ section.pk }}-{{ item.pk }}"
role="region"
aria-labelledby="faq-question-{{ section.pk }}-{{ item.pk }}"
hidden
>
<div class="rich-content">{{ item.rendered_content }}</div>
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endwith %}
</div>
</section>
{% elif section.section_type == "video" %}
{% include "partials/_video_section.html" with section=section %}
+7
View File
@@ -0,0 +1,7 @@
{% if page_obj and page_obj.paginator.num_pages > 1 %}
<nav class="pagination" aria-label="Pagination">
{% if page_obj.has_previous %}<a class="btn-ghost btn-sm" href="?page={{ page_obj.previous_page_number }}">Previous</a>{% endif %}
<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
{% if page_obj.has_next %}<a class="btn-ghost btn-sm" href="?page={{ page_obj.next_page_number }}">Next</a>{% endif %}
</nav>
{% endif %}
@@ -1,34 +0,0 @@
{% load static %}
<div class="sub-downloads-channel{% if channel.is_previous %} sub-downloads-channel--prev{% endif %}">
{% include "products/_release_channel_badge.html" with version=channel.version %}
{% if channel.install_cells %}
<div class="sub-downloads-grid">
{% for cell in channel.install_cells %}
<div class="sub-dl-item">
<div class="sub-dl-icon-wrap">
{% if cell.icon %}
<img src="{% static cell.icon %}" alt="" width="32" height="32" class="platform-icon sub-dl-icon" aria-hidden="true" />
{% else %}
<span class="sub-dl-source-mark" aria-hidden="true">
<svg class="sub-dl-source-svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round">
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
</svg>
</span>
{% endif %}
</div>
<span class="sub-dl-platform">{{ cell.label }}</span>
<span class="download-version">{{ channel.version.version }}</span>
<a href="{{ cell.url }}" class="btn-primary btn--sm"{% if cell.external %} target="_blank" rel="noopener noreferrer"{% endif %}>{% if cell.label == "Source code" %}Source{% else %}Download{% endif %}</a>
</div>
{% endfor %}
</div>
{% endif %}
{% if channel.version.package_resource_url %}
<p class="sub-install-extra-resource">
<a href="{{ channel.version.package_resource_url }}" target="_blank" rel="noopener noreferrer">Package resource</a>
</p>
{% endif %}
{% if channel.version.release_notes %}
<p class="sub-release-notes">{{ channel.version.release_notes }}</p>
{% endif %}
</div>
@@ -1,10 +0,0 @@
{% if version.display_channel_label %}
<span class="release-channel-label release-channel-label--{{ version.display_channel_css_modifier }}">
{% if version.release_channel == "stable" %}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
{% endif %}
{{ version.display_channel_label }}
</span>
{% endif %}
-69
View File
@@ -1,69 +0,0 @@
{% load static %}
{% if show_releases_section %}
{% if distribution_installable %}
<section class="sub-downloads fade-in" aria-labelledby="sub-dl-heading">
<div class="sub-downloads-header">
<h2 class="sub-downloads-title" id="sub-dl-heading">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Downloads
</h2>
{% if downloads_section_link %}
<a href="{{ downloads_section_link.url }}" class="sub-downloads-corner-link link-arrow" target="_blank" rel="noopener noreferrer">{{ downloads_section_link.text }}</a>
{% endif %}
</div>
{% if featured_version %}
{% include "products/_installable_download_channel.html" with channel=featured_channel %}
{% endif %}
{% for channel in inline_download_channels %}
{% include "products/_installable_download_channel.html" %}
{% endfor %}
{% if show_older_versions_link %}
<p class="sub-older-versions-inner">
<a href="{{ versions_archive_url }}" class="link-arrow">Older releases</a>
</p>
{% endif %}
</section>
{% elif distribution_package %}
<section class="sub-downloads sub-downloads--package fade-in" aria-labelledby="sub-resources-heading">
<div class="sub-downloads-header">
<h2 class="sub-downloads-title" id="sub-resources-heading">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
Resources
</h2>
{% if downloads_section_link %}
<a href="{{ downloads_section_link.url }}" class="sub-downloads-corner-link link-arrow" target="_blank" rel="noopener noreferrer">{{ downloads_section_link.text }}</a>
{% endif %}
</div>
<ul class="pkg-version-list" role="list">
{% for ver in package_versions %}
<li class="pkg-version-row">
<div class="pkg-version-meta">
<span class="download-version">{{ ver.version }}</span>
{% include "products/_release_channel_badge.html" with version=ver %}
</div>
<div class="pkg-version-actions">
{% if ver.package_resource_url %}
<a href="{{ ver.package_resource_url }}" class="btn-primary btn--sm" target="_blank" rel="noopener noreferrer">{{ package_resource_button_text }}</a>
{% endif %}
{% if ver.resolved_source_code_url %}
<a href="{{ ver.resolved_source_code_url }}" class="btn-ghost btn--sm"{% if ver.source_code_url %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ package_source_button_text }}</a>
{% endif %}
</div>
{% if ver.release_notes %}
<p class="pkg-version-notes">{{ ver.release_notes }}</p>
{% endif %}
</li>
{% endfor %}
</ul>
</section>
{% endif %}
{% endif %}
@@ -1,70 +0,0 @@
{% load static %}
{% if not archive_versions %}
<p class="versions-empty glass-card">There are no archived releases{% if sub_product %} for this module{% else %} for this product{% endif %}.</p>
{% elif distribution_installable %}
<div class="versions-table-wrap glass-card fade-in">
<table class="versions-table">
<thead>
<tr>
<th scope="col">Version</th>
{% for fname, label, icon in archive_specs %}
<th scope="col">{{ label }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in archive_rows_installable %}
<tr>
<th scope="row">
<span class="versions-table-version">{{ row.version_obj.version }}</span>
{% include "products/_release_channel_badge.html" with version=row.version_obj %}
</th>
{% for cell in row.cells %}
<td>
{% if cell.url %}
{% if cell.icon %}
<a href="{{ cell.url }}" class="versions-table-link"{% if cell.external %} target="_blank" rel="noopener noreferrer"{% endif %}>
<img src="{% static cell.icon %}" alt="" width="22" height="22" class="versions-table-icon" aria-hidden="true" />
<span class="sr-only">{{ cell.label }}</span>
</a>
{% else %}
<a href="{{ cell.url }}" class="versions-table-text-link"{% if cell.external %} target="_blank" rel="noopener noreferrer"{% endif %}>Source</a>
{% endif %}
{% else %}
<span class="versions-na">—</span>
{% endif %}
</td>
{% endfor %}
</tr>
{% if row.version_obj.release_notes %}
<tr class="versions-notes-row">
<td colspan="{{ archive_specs|length|add:1 }}">
<span class="versions-notes-label">Notes</span>
<p class="versions-notes-body">{{ row.version_obj.release_notes }}</p>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<ul class="versions-package-list" role="list">
{% for ver in archive_versions %}
<li class="versions-package-card glass-card fade-in">
<div class="versions-package-head">
<div class="pkg-version-meta">
<span class="download-version">{{ ver.version }}</span>
{% include "products/_release_channel_badge.html" with version=ver %}
</div>
{% if ver.package_resource_url %}
<a href="{{ ver.package_resource_url }}" class="btn-ghost btn--sm" target="_blank" rel="noopener noreferrer">{{ package_resource_button_text }}</a>
{% endif %}
</div>
{% if ver.release_notes %}
<p class="versions-notes-body">{{ ver.release_notes }}</p>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
+3 -4
View File
@@ -18,13 +18,13 @@
<ol class="breadcrumb-list" role="list">
<li><a href="{% url 'pages:home' %}">Home</a></li>
<li aria-hidden="true" class="breadcrumb-sep">›</li>
<li><a href="{% url 'products:overview' %}">Products</a></li>
<li><a href="{% url 'products:overview' %}">Projects</a></li>
<li aria-hidden="true" class="breadcrumb-sep">›</li>
<li aria-current="page">{{ main_product.name }}</li>
</ol>
</nav>
<div class="page-hero-content">
<div class="section-badge">Product</div>
<div class="section-badge">Project</div>
<h1 class="page-hero-title" id="main-product-heading">{{ main_product.name }}</h1>
<p class="page-hero-subtitle">{{ main_product.short_description }}</p>
</div>
@@ -48,7 +48,6 @@
</div>
</div>
{% include "products/_releases_section.html" %}
</div>
</div>
</section>
@@ -56,7 +55,7 @@
{% include "partials/_page_videos.html" with videos=product_videos %}
{% if articles %}
<section class="section" aria-label="Product articles">
<section class="section" aria-label="Project articles">
<div class="container">
{% include "products/_article_list.html" %}
</div>
+7 -7
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Products{% endblock %}
{% block title %}Projects{% endblock %}
{% block meta_description %}Explore com2care tools for collaborative medical imaging, radiomics, reproducible workflows, and research communication.{% endblock %}
{% block content %}
@@ -13,16 +13,16 @@
</div>
<div class="container">
<div class="page-hero-content">
<div class="section-badge">Software Suite</div>
<h1 class="page-hero-title" id="products-overview-heading">Our Products</h1>
<p class="page-hero-subtitle">Advanced tools for medical imaging research and radiomics analysis.</p>
<div class="section-badge">Research Portfolio</div>
<h1 class="page-hero-title" id="products-overview-heading">Our Projects</h1>
<p class="page-hero-subtitle">Collaborative work in medical imaging research and radiomics analysis.</p>
</div>
</div>
</section>
<section class="section" aria-labelledby="products-list-heading">
<div class="container">
<h2 class="sr-only" id="products-list-heading">Product List</h2>
<h2 class="sr-only" id="products-list-heading">Project List</h2>
{% if main_products %}
<div class="products-overview-grid">
{% for product in main_products %}
@@ -35,7 +35,7 @@
<div class="product-overview-body">
<h2 class="product-overview-title">{{ product.name }}</h2>
<p class="product-overview-short">{{ product.short_description }}</p>
<p class="product-overview-desc" data-readmore="110">{{ product.description }}</p>
<div class="product-overview-desc rich-content" data-readmore="110">{{ product.rendered_description }}</div>
<a href="{{ product.get_absolute_url }}" class="btn-primary">Explore {{ product.name }}</a>
</div>
{% with subs=product.sub_products.all %}
@@ -64,7 +64,7 @@
</div>
{% else %}
<div class="empty-state-block glass-card">
<p>No products available at this time. Check back soon.</p>
<p>No projects available at this time. Check back soon.</p>
</div>
{% endif %}
</div>
+2 -3
View File
@@ -16,7 +16,7 @@
<ol class="breadcrumb-list" role="list">
<li><a href="{% url 'pages:home' %}">Home</a></li>
<li aria-hidden="true" class="breadcrumb-sep">›</li>
<li><a href="{% url 'products:overview' %}">Products</a></li>
<li><a href="{% url 'products:overview' %}">Projects</a></li>
<li aria-hidden="true" class="breadcrumb-sep">›</li>
<li><a href="{{ main_product.get_absolute_url }}">{{ main_product.name }}</a></li>
<li aria-hidden="true" class="breadcrumb-sep">›</li>
@@ -47,7 +47,6 @@
<div class="rich-content" data-readmore="220">{{ sub_product.rendered_description }}</div>
</div>
{% include "products/_releases_section.html" %}
{% include "partials/_page_videos.html" with videos=product_videos %}
@@ -72,7 +71,7 @@
{% endfor %}
</ul>
{% else %}
<p class="sidebar-empty">No other modules in this product.</p>
<p class="sidebar-empty">No other modules in this project.</p>
{% endif %}
</div>
<a href="{{ main_product.get_absolute_url }}" class="btn-ghost sidebar-back-btn">
-43
View File
@@ -1,43 +0,0 @@
{% extends "base.html" %}
{% block title %}Older releases — {% if sub_product %}{{ sub_product.name }} — {% endif %}{{ main_product.name }}{% endblock %}
{% block meta_description %}Archived releases{% if sub_product %} for {{ sub_product.name }}{% else %} for {{ main_product.name }}{% endif %}.{% endblock %}
{% block content %}
<section class="page-hero" aria-labelledby="versions-heading">
<div class="page-hero-blobs" aria-hidden="true">
<div class="blob blob--1"></div>
<div class="blob blob--2"></div>
</div>
<div class="container">
<nav class="breadcrumb" aria-label="Breadcrumb">
<ol class="breadcrumb-list" role="list">
<li><a href="{% url 'pages:home' %}">Home</a></li>
<li aria-hidden="true" class="breadcrumb-sep">›</li>
<li><a href="{% url 'products:overview' %}">Products</a></li>
<li aria-hidden="true" class="breadcrumb-sep">›</li>
<li><a href="{{ main_product.get_absolute_url }}">{{ main_product.name }}</a></li>
{% if sub_product %}
<li aria-hidden="true" class="breadcrumb-sep">›</li>
<li><a href="{{ sub_product.get_absolute_url }}">{{ sub_product.name }}</a></li>
{% endif %}
<li aria-hidden="true" class="breadcrumb-sep">›</li>
<li aria-current="page">Older releases</li>
</ol>
</nav>
<div class="page-hero-content">
<div class="section-badge">{% if sub_product %}{{ sub_product.name }}{% else %}{{ main_product.name }}{% endif %}</div>
<h1 class="page-hero-title" id="versions-heading">Older releases</h1>
<p class="page-hero-subtitle">Archived versions of this {% if sub_product %}module{% else %}product{% endif %}.</p>
</div>
</div>
</section>
<section class="section versions-archive-section">
<div class="container">
{% include "products/_versions_archive_body.html" %}
</div>
</section>
{% endblock %}