diff --git a/apps/core/admin.py b/apps/core/admin.py
index d0e3323..9fcde81 100644
--- a/apps/core/admin.py
+++ b/apps/core/admin.py
@@ -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):
diff --git a/apps/core/management/commands/seed_content.py b/apps/core/management/commands/seed_content.py
index 815e226..dbccf6f 100644
--- a/apps/core/management/commands/seed_content.py
+++ b/apps/core/management/commands/seed_content.py
@@ -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",
diff --git a/apps/core/migrations/0005_sitebranding_accent_color_and_more.py b/apps/core/migrations/0005_sitebranding_accent_color_and_more.py
new file mode 100644
index 0000000..cf59704
--- /dev/null
+++ b/apps/core/migrations/0005_sitebranding_accent_color_and_more.py
@@ -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}$')]),
+ ),
+ ]
diff --git a/apps/core/models.py b/apps/core/models.py
index 4d7e1ee..fe46ece 100644
--- a/apps/core/models.py
+++ b/apps/core/models.py
@@ -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(
diff --git a/apps/core/tests/test_site_branding.py b/apps/core/tests/test_site_branding.py
index 4054544..24af727 100644
--- a/apps/core/tests/test_site_branding.py
+++ b/apps/core/tests/test_site_branding.py
@@ -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")
+ self.assertContains(response, "--accent-blue:#123456")
diff --git a/apps/core/video.py b/apps/core/video.py
index ea0305d..2c310a9 100644
--- a/apps/core/video.py
+++ b/apps/core/video.py
@@ -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,
diff --git a/apps/pages/admin.py b/apps/pages/admin.py
index 1106e98..3ad95d8 100644
--- a/apps/pages/admin.py
+++ b/apps/pages/admin.py
@@ -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")}),
- )
diff --git a/apps/pages/migrations/0019_blogpost_alter_aboutsection_video_styled_background_and_more.py b/apps/pages/migrations/0019_blogpost_alter_aboutsection_video_styled_background_and_more.py
new file mode 100644
index 0000000..3b55d26
--- /dev/null
+++ b/apps/pages/migrations/0019_blogpost_alter_aboutsection_video_styled_background_and_more.py
@@ -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.'),
+ ),
+ ]
diff --git a/apps/pages/models.py b/apps/pages/models.py
index e8ea274..7417c8d 100644
--- a/apps/pages/models.py
+++ b/apps/pages/models.py
@@ -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)
diff --git a/apps/pages/tests/test_video.py b/apps/pages/tests/test_video.py
index 8ab71a0..117bec2 100644
--- a/apps/pages/tests/test_video.py
+++ b/apps/pages/tests/test_video.py
@@ -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, "Bold ")
diff --git a/apps/pages/tests/test_views.py b/apps/pages/tests/test_views.py
index 096118f..cf2b531 100644
--- a/apps/pages/tests/test_views.py
+++ b/apps/pages/tests/test_views.py
@@ -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, "Formatted ")
+
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, "
Results ")
+ self.assertContains(detail, "Promising ")
- 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:
diff --git a/apps/pages/urls.py b/apps/pages/urls.py
index a525aba..0f6e9fc 100644
--- a/apps/pages/urls.py
+++ b/apps/pages/urls.py
@@ -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//", views.BlogPostDetailView.as_view(), name="blog_detail"),
path(
"videos//",
views.PageVideoArchiveView.as_view(),
diff --git a/apps/pages/views.py b/apps/pages/views.py
index d0fe56f..bd56ea3 100644
--- a/apps/pages/views.py
+++ b/apps/pages/views.py
@@ -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):
diff --git a/apps/products/admin.py b/apps/products/admin.py
index ee95834..7b65bba 100644
--- a/apps/products/admin.py
+++ b/apps/products/admin.py
@@ -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 "—"
diff --git a/apps/products/apps.py b/apps/products/apps.py
index 417a2ba..1d37333 100644
--- a/apps/products/apps.py
+++ b/apps/products/apps.py
@@ -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
diff --git a/apps/products/migrations/0022_alter_mainproduct_options_alter_productvideo_options_and_more.py b/apps/products/migrations/0022_alter_mainproduct_options_alter_productvideo_options_and_more.py
new file mode 100644
index 0000000..37bab7f
--- /dev/null
+++ b/apps/products/migrations/0022_alter_mainproduct_options_alter_productvideo_options_and_more.py
@@ -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.'),
+ ),
+ ]
diff --git a/apps/products/models.py b/apps/products/models.py
index 5cb3f40..82b7013 100644
--- a/apps/products/models.py
+++ b/apps/products/models.py
@@ -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)
diff --git a/apps/products/tests/test_views.py b/apps/products/tests/test_views.py
index ba38df5..c6ab259 100644
--- a/apps/products/tests/test_views.py
+++ b/apps/products/tests/test_views.py
@@ -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")
diff --git a/apps/products/urls.py b/apps/products/urls.py
index 74ac644..1576120 100644
--- a/apps/products/urls.py
+++ b/apps/products/urls.py
@@ -6,11 +6,6 @@ app_name = "products"
urlpatterns = [
path("", views.ProductOverviewView.as_view(), name="overview"),
- path(
- "releases///download/",
- views.ReleaseAssetDownloadView.as_view(),
- name="release_asset_download",
- ),
path(
"/videos/",
views.ProductVideoArchiveView.as_view(),
@@ -26,16 +21,6 @@ urlpatterns = [
views.MainProductDetailView.as_view(),
name="main_product_detail",
),
- path(
- "/versions/",
- views.MainProductOlderVersionsView.as_view(),
- name="main_product_versions",
- ),
- path(
- "//versions/",
- views.SubProductOlderVersionsView.as_view(),
- name="sub_product_versions",
- ),
path(
"//",
views.SubProductDetailView.as_view(),
diff --git a/apps/products/views.py b/apps/products/views.py
index cfc6201..99adff9 100644
--- a/apps/products/views.py
+++ b/apps/products/views.py
@@ -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
diff --git a/config/urls.py b/config/urls.py
index a9f2137..681e65c 100644
--- a/config/urls.py
+++ b/config/urls.py
@@ -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")),
]
diff --git a/docker-compose.yml b/docker-compose.yml
index c42db3b..71ec049 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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}
diff --git a/static/css/main.css b/static/css/main.css
index dab15d3..0a7431b 100644
--- a/static/css/main.css
+++ b/static/css/main.css
@@ -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;
diff --git a/static/js/main.js b/static/js/main.js
index 39f4c4f..edbfcfe 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -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();
diff --git a/templates/base.html b/templates/base.html
index f39e8e3..edab20b 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -4,12 +4,12 @@
-
-
+
+
- {% block title %}Communication to Care{% endblock %} | Communication to Care
+ {% block title %}{{ site_branding.site_name }}{% endblock %} | {{ site_branding.site_name }}
{% if site_branding.website_icon %}
@@ -21,6 +21,7 @@
+
{% block extra_css %}{% endblock %}
diff --git a/templates/pages/about.html b/templates/pages/about.html
index b86710f..73512c7 100644
--- a/templates/pages/about.html
+++ b/templates/pages/about.html
@@ -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 %}
diff --git a/templates/pages/blog_detail.html b/templates/pages/blog_detail.html
new file mode 100644
index 0000000..68eb82e
--- /dev/null
+++ b/templates/pages/blog_detail.html
@@ -0,0 +1,32 @@
+{% extends "base.html" %}
+
+{% block title %}{{ post.title }}{% endblock %}
+{% block meta_description %}{{ post.excerpt|default:post.title }}{% endblock %}
+
+{% block content %}
+
+
+
+
+ {% if post.cover_image %}
{% endif %}
+
{{ post.rendered_content }}
+
+
+
+{% endblock %}
diff --git a/templates/pages/blog_list.html b/templates/pages/blog_list.html
new file mode 100644
index 0000000..b1a1af4
--- /dev/null
+++ b/templates/pages/blog_list.html
@@ -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 %}
+
+
+
+
+
Insights and Updates
+
Blog
+
Research stories, project updates, and practical ideas from our community.
+
+
+
+
+
+
+ {% if posts %}
+
+ {% for post in posts %}
+
+ {% if post.cover_image %}
+
+
+
+ {% endif %}
+
+
{{ post.published_at|date:"F j, Y" }}{% if post.author_name %} · {{ post.author_name }}{% endif %}
+
+ {% if post.excerpt %}
{{ post.excerpt }}
{% endif %}
+
Read article
+
+
+ {% endfor %}
+
+ {% include "partials/_pagination.html" with page_obj=page_obj %}
+ {% else %}
+
No blog posts have been published yet.
+ {% endif %}
+
+
+{% endblock %}
diff --git a/templates/pages/contact.html b/templates/pages/contact.html
index 2dec9a5..9672b16 100644
--- a/templates/pages/contact.html
+++ b/templates/pages/contact.html
@@ -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 @@
-
Get in Touch
-
Contact Us
-
Have a product, evaluation, or research question? Send the com2care team a message below.
+
Join the Community
+
Become a Member
+
Tell us how you would like to contribute, collaborate, or take part in the com2care research community.
@@ -27,7 +27,7 @@