From 588d95f88927a9a80a952eef4461d9c0a74d7241 Mon Sep 17 00:00:00 2001 From: mohamad Date: Thu, 24 Sep 2026 02:32:27 +0330 Subject: [PATCH] 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. --- apps/core/admin.py | 43 ++++ apps/core/management/commands/seed_content.py | 126 +---------- ...0005_sitebranding_accent_color_and_more.py | 69 ++++++ apps/core/models.py | 66 +++++- apps/core/tests/test_site_branding.py | 13 ++ apps/core/video.py | 2 +- apps/pages/admin.py | 44 ++-- ...ection_video_styled_background_and_more.py | 72 ++++++ apps/pages/models.py | 74 ++++++- apps/pages/tests/test_video.py | 20 +- apps/pages/tests/test_views.py | 61 ++--- apps/pages/urls.py | 3 +- apps/pages/views.py | 58 +++-- apps/products/admin.py | 127 +---------- apps/products/apps.py | 2 +- ...ons_alter_productvideo_options_and_more.py | 90 ++++++++ apps/products/models.py | 103 +-------- apps/products/tests/test_views.py | 208 +----------------- apps/products/urls.py | 15 -- apps/products/views.py | 97 +------- config/urls.py | 2 +- docker-compose.yml | 4 +- static/css/main.css | 22 ++ static/js/main.js | 31 --- templates/base.html | 7 +- templates/pages/about.html | 2 +- templates/pages/blog_detail.html | 32 +++ templates/pages/blog_list.html | 47 ++++ templates/pages/contact.html | 12 +- templates/pages/faq.html | 67 ------ templates/pages/home.html | 4 +- templates/partials/_footer.html | 22 +- templates/partials/_navbar.html | 22 +- templates/partials/_page_sections.html | 70 +----- templates/partials/_pagination.html | 7 + .../_installable_download_channel.html | 34 --- .../products/_release_channel_badge.html | 10 - templates/products/_releases_section.html | 69 ------ .../products/_versions_archive_body.html | 70 ------ templates/products/main_detail.html | 7 +- templates/products/overview.html | 14 +- templates/products/sub_detail.html | 5 +- templates/products/versions_archive.html | 43 ---- 43 files changed, 703 insertions(+), 1193 deletions(-) create mode 100644 apps/core/migrations/0005_sitebranding_accent_color_and_more.py create mode 100644 apps/pages/migrations/0019_blogpost_alter_aboutsection_video_styled_background_and_more.py create mode 100644 apps/products/migrations/0022_alter_mainproduct_options_alter_productvideo_options_and_more.py create mode 100644 templates/pages/blog_detail.html create mode 100644 templates/pages/blog_list.html delete mode 100644 templates/pages/faq.html create mode 100644 templates/partials/_pagination.html delete mode 100644 templates/products/_installable_download_channel.html delete mode 100644 templates/products/_release_channel_badge.html delete mode 100644 templates/products/_releases_section.html delete mode 100644 templates/products/_versions_archive_body.html delete mode 100644 templates/products/versions_archive.html 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 %} +
+
+ +
+ +

{{ post.published_at|date:"F j, Y" }}{% if post.author_name %} · {{ post.author_name }}{% endif %}

+

{{ post.title }}

+ {% if post.excerpt %}

{{ post.excerpt }}

{% endif %} +
+
+
+
+ {% if post.cover_image %}{{ post.cover_image_alt|default:post.title }}{% 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 %} + + {{ post.cover_image_alt|default:post.title }} + + {% endif %} +
+

{{ post.published_at|date:"F j, Y" }}{% if post.author_name %} · {{ post.author_name }}{% endif %}

+

{{ post.title }}

+ {% 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 @@
-

Send a Message

+

Membership Request

{% csrf_token %} diff --git a/templates/pages/faq.html b/templates/pages/faq.html deleted file mode 100644 index 6689d17..0000000 --- a/templates/pages/faq.html +++ /dev/null @@ -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 %} - -
- -
-
-
Help
-

FAQ

-

Frequently Asked Questions

-
-
-
- -{% include "partials/_page_videos.html" with videos=faq_videos %} - -
-
-

FAQ List

- {% if faq_entries %} -
- {% for entry in faq_entries %} -
- - -
- {% endfor %} -
- {% else %} -

No FAQ entries available yet.

- {% endif %} - -
-

Still Have Questions?

-

Can't find the answer you're looking for? Get in touch with our support team.

- Contact Support -
-
-
- -{% endblock %} diff --git a/templates/pages/home.html b/templates/pages/home.html index 0fcd045..e2e77e1 100644 --- a/templates/pages/home.html +++ b/templates/pages/home.html @@ -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 @@
{% if site_branding.hero_logo %} - {{ site_branding.hero_logo_alt|default:'Communication to Care' }} + {{ site_branding.hero_logo_alt|default:site_branding.site_name }} {% elif hero and hero.image %} {{ hero.image_alt }} {% else %} diff --git a/templates/partials/_footer.html b/templates/partials/_footer.html index de1825a..f514c5e 100644 --- a/templates/partials/_footer.html +++ b/templates/partials/_footer.html @@ -8,12 +8,12 @@ {% if footer_main_products %} @@ -45,7 +45,7 @@ {% if site_contact.has_footer_contact %} @@ -55,7 +55,7 @@ - {% endif %} - {% endwith %} -
- - {% elif section.section_type == "video" %} {% include "partials/_video_section.html" with section=section %} diff --git a/templates/partials/_pagination.html b/templates/partials/_pagination.html new file mode 100644 index 0000000..89bfdb0 --- /dev/null +++ b/templates/partials/_pagination.html @@ -0,0 +1,7 @@ +{% if page_obj and page_obj.paginator.num_pages > 1 %} + +{% endif %} diff --git a/templates/products/_installable_download_channel.html b/templates/products/_installable_download_channel.html deleted file mode 100644 index d8f7eaa..0000000 --- a/templates/products/_installable_download_channel.html +++ /dev/null @@ -1,34 +0,0 @@ -{% load static %} -
- {% include "products/_release_channel_badge.html" with version=channel.version %} - {% if channel.install_cells %} -
- {% for cell in channel.install_cells %} -
-
- {% if cell.icon %} - - {% else %} - - {% endif %} -
- {{ cell.label }} - {{ channel.version.version }} - {% if cell.label == "Source code" %}Source{% else %}Download{% endif %} -
- {% endfor %} -
- {% endif %} - {% if channel.version.package_resource_url %} -

- Package resource -

- {% endif %} - {% if channel.version.release_notes %} -

{{ channel.version.release_notes }}

- {% endif %} -
diff --git a/templates/products/_release_channel_badge.html b/templates/products/_release_channel_badge.html deleted file mode 100644 index e0f8363..0000000 --- a/templates/products/_release_channel_badge.html +++ /dev/null @@ -1,10 +0,0 @@ -{% if version.display_channel_label %} - - {% if version.release_channel == "stable" %} - - {% endif %} - {{ version.display_channel_label }} - -{% endif %} diff --git a/templates/products/_releases_section.html b/templates/products/_releases_section.html deleted file mode 100644 index 48390e8..0000000 --- a/templates/products/_releases_section.html +++ /dev/null @@ -1,69 +0,0 @@ -{% load static %} -{% if show_releases_section %} -{% if distribution_installable %} -
-
-

- - Downloads -

- {% if downloads_section_link %} - {{ downloads_section_link.text }} - {% endif %} -
- - {% 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 %} -

- Older releases -

- {% endif %} -
- -{% elif distribution_package %} -
-
-

- - Resources -

- {% if downloads_section_link %} - {{ downloads_section_link.text }} - {% endif %} -
-
    - {% for ver in package_versions %} -
  • -
    - {{ ver.version }} - {% include "products/_release_channel_badge.html" with version=ver %} -
    -
    - {% if ver.package_resource_url %} - {{ package_resource_button_text }} - {% endif %} - {% if ver.resolved_source_code_url %} - {{ package_source_button_text }} - {% endif %} -
    - {% if ver.release_notes %} -

    {{ ver.release_notes }}

    - {% endif %} -
  • - {% endfor %} -
-
-{% endif %} -{% endif %} diff --git a/templates/products/_versions_archive_body.html b/templates/products/_versions_archive_body.html deleted file mode 100644 index ab39e92..0000000 --- a/templates/products/_versions_archive_body.html +++ /dev/null @@ -1,70 +0,0 @@ -{% load static %} -{% if not archive_versions %} -

There are no archived releases{% if sub_product %} for this module{% else %} for this product{% endif %}.

-{% elif distribution_installable %} -
- - - - - {% for fname, label, icon in archive_specs %} - - {% endfor %} - - - - {% for row in archive_rows_installable %} - - - {% for cell in row.cells %} - - {% endfor %} - - {% if row.version_obj.release_notes %} - - - - {% endif %} - {% endfor %} - -
Version{{ label }}
- {{ row.version_obj.version }} - {% include "products/_release_channel_badge.html" with version=row.version_obj %} - - {% if cell.url %} - {% if cell.icon %} - - - {{ cell.label }} - - {% else %} - Source - {% endif %} - {% else %} - — - {% endif %} -
- Notes -

{{ row.version_obj.release_notes }}

-
-
-{% else %} -
    - {% for ver in archive_versions %} -
  • -
    -
    - {{ ver.version }} - {% include "products/_release_channel_badge.html" with version=ver %} -
    - {% if ver.package_resource_url %} - {{ package_resource_button_text }} - {% endif %} -
    - {% if ver.release_notes %} -

    {{ ver.release_notes }}

    - {% endif %} -
  • - {% endfor %} -
-{% endif %} diff --git a/templates/products/main_detail.html b/templates/products/main_detail.html index 111daad..e85b1f1 100644 --- a/templates/products/main_detail.html +++ b/templates/products/main_detail.html @@ -18,13 +18,13 @@
-
Product
+
Project

{{ main_product.name }}

{{ main_product.short_description }}

@@ -48,7 +48,6 @@
- {% include "products/_releases_section.html" %} @@ -56,7 +55,7 @@ {% include "partials/_page_videos.html" with videos=product_videos %} {% if articles %} -
+
{% include "products/_article_list.html" %}
diff --git a/templates/products/overview.html b/templates/products/overview.html index b40d6b0..923038c 100644 --- a/templates/products/overview.html +++ b/templates/products/overview.html @@ -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 @@
-
Software Suite
-

Our Products

-

Advanced tools for medical imaging research and radiomics analysis.

+
Research Portfolio
+

Our Projects

+

Collaborative work in medical imaging research and radiomics analysis.

-

Product List

+

Project List

{% if main_products %}
{% for product in main_products %} @@ -35,7 +35,7 @@

{{ product.name }}

{{ product.short_description }}

-

{{ product.description }}

+
{{ product.rendered_description }}
Explore {{ product.name }}
{% with subs=product.sub_products.all %} @@ -64,7 +64,7 @@
{% else %}
-

No products available at this time. Check back soon.

+

No projects available at this time. Check back soon.

{% endif %}
diff --git a/templates/products/sub_detail.html b/templates/products/sub_detail.html index f45e57a..ca2acde 100644 --- a/templates/products/sub_detail.html +++ b/templates/products/sub_detail.html @@ -16,7 +16,7 @@