feat: rebuild site content management
Replace legacy FAQ, downloads, and release flows with project and blog publishing. Add configurable branding and theme fields plus clean-start Compose seeding support.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
@@ -5,9 +6,36 @@ from django.urls import reverse
|
||||
from .models import SiteBranding, SiteContact
|
||||
|
||||
|
||||
class SiteBrandingForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = SiteBranding
|
||||
fields = "__all__"
|
||||
widgets = {
|
||||
field: forms.TextInput(attrs={"type": "color"})
|
||||
for field in (
|
||||
"border_color",
|
||||
"background_color",
|
||||
"surface_color",
|
||||
"primary_color",
|
||||
"secondary_color",
|
||||
"accent_color",
|
||||
"text_color",
|
||||
"muted_text_color",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@admin.register(SiteBranding)
|
||||
class SiteBrandingAdmin(admin.ModelAdmin):
|
||||
form = SiteBrandingForm
|
||||
fieldsets = (
|
||||
(
|
||||
"Site identity",
|
||||
{
|
||||
"fields": ("site_name", "short_name", "tagline", "meta_description"),
|
||||
"description": "Names and text used throughout the public website.",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Brand assets",
|
||||
{
|
||||
@@ -44,6 +72,21 @@ class SiteBrandingAdmin(admin.ModelAdmin):
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Site colours",
|
||||
{
|
||||
"fields": (
|
||||
"background_color",
|
||||
"surface_color",
|
||||
"primary_color",
|
||||
"secondary_color",
|
||||
"accent_color",
|
||||
"text_color",
|
||||
"muted_text_color",
|
||||
),
|
||||
"description": "Use six-digit hex colours. Changes apply across the public site.",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
|
||||
@@ -7,8 +7,6 @@ from apps.core.models import SiteBranding, SiteContact
|
||||
from apps.pages.models import (
|
||||
AboutSection,
|
||||
AboutSectionItem,
|
||||
DownloadItem,
|
||||
FAQEntry,
|
||||
HeroSection,
|
||||
HomepageSection,
|
||||
HomepageSectionItem,
|
||||
@@ -214,62 +212,6 @@ MAIN_PRODUCTS = [
|
||||
},
|
||||
]
|
||||
|
||||
FAQ_ENTRIES = [
|
||||
{
|
||||
"question": "How is Communication to Care licensed?",
|
||||
"answer": (
|
||||
"Licensing and deployment terms are provided with each com2care release. "
|
||||
"Contact support@com2care.com for research, institutional, or evaluation access."
|
||||
),
|
||||
"order": 1,
|
||||
},
|
||||
{
|
||||
"question": "How do I acknowledge Communication to Care in my research?",
|
||||
"answer": (
|
||||
"Mention Communication to Care (com2care) and the software version used in your "
|
||||
"methods section. Release-specific citation guidance can be requested from "
|
||||
"support@com2care.com."
|
||||
),
|
||||
"order": 2,
|
||||
},
|
||||
{
|
||||
"question": "Which operating systems does Communication to Care support?",
|
||||
"answer": (
|
||||
"Communication to Care currently fully supports Windows 10 and above (64-bit). "
|
||||
"macOS and Linux packages are represented in this demonstration dataset as upcoming "
|
||||
"channels. Check each product module page for current release information."
|
||||
),
|
||||
"order": 3,
|
||||
},
|
||||
{
|
||||
"question": "Can I install a new version over an existing installation?",
|
||||
"answer": (
|
||||
"Yes, you can install the new version without removing the previous one. "
|
||||
"However, if you encounter any problems after upgrading, we recommend "
|
||||
"uninstalling the old version first, then performing a clean installation "
|
||||
"of the new release."
|
||||
),
|
||||
"order": 4,
|
||||
},
|
||||
{
|
||||
"question": "Is Communication to Care suitable for clinical use?",
|
||||
"answer": (
|
||||
"Communication to Care is designed and intended exclusively for research purposes. "
|
||||
"It is not certified for clinical diagnostic use. Always consult with "
|
||||
"qualified medical professionals for clinical decisions."
|
||||
),
|
||||
"order": 5,
|
||||
},
|
||||
{
|
||||
"question": "Where can I get support or report issues?",
|
||||
"answer": (
|
||||
"Use the contact form or email support@com2care.com. Include the software version, "
|
||||
"operating system, a short reproduction description, and non-sensitive logs when relevant."
|
||||
),
|
||||
"order": 6,
|
||||
},
|
||||
]
|
||||
|
||||
HOMEPAGE_SECTIONS = [
|
||||
{
|
||||
"section_type": HomepageSection.TYPE_FEATURES,
|
||||
@@ -329,8 +271,8 @@ HOMEPAGE_SECTIONS = [
|
||||
},
|
||||
{
|
||||
"section_type": HomepageSection.TYPE_PRODUCTS,
|
||||
"badge": "Our Software",
|
||||
"title": "Products",
|
||||
"badge": "Our Projects",
|
||||
"title": "Projects",
|
||||
"description": "Explore our suite of medical imaging and radiomics tools.",
|
||||
"order": 4,
|
||||
"items": [],
|
||||
@@ -447,44 +389,11 @@ ABOUT_SECTIONS = [
|
||||
},
|
||||
]
|
||||
|
||||
DOWNLOAD_ITEMS = [
|
||||
{
|
||||
"name": "com2care Desktop",
|
||||
"platform": "windows",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://com2care.com/downloads/",
|
||||
"description": "Demonstration release channel for Windows 10 and above (64-bit).",
|
||||
"is_active": True,
|
||||
"order": 1,
|
||||
},
|
||||
{
|
||||
"name": "com2care Desktop",
|
||||
"platform": "macos",
|
||||
"version": "Coming Soon",
|
||||
"download_url": "#",
|
||||
"description": "macOS version is under development.",
|
||||
"is_active": False,
|
||||
"order": 2,
|
||||
},
|
||||
{
|
||||
"name": "com2care Desktop",
|
||||
"platform": "linux",
|
||||
"version": "Coming Soon",
|
||||
"download_url": "#",
|
||||
"description": "Linux version is under development.",
|
||||
"is_active": False,
|
||||
"order": 3,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seed a complete com2care demonstration site."
|
||||
|
||||
CONTENT_MODELS = (
|
||||
MainProduct,
|
||||
FAQEntry,
|
||||
DownloadItem,
|
||||
HeroSection,
|
||||
HomepageSection,
|
||||
AboutSection,
|
||||
@@ -520,15 +429,11 @@ class Command(BaseCommand):
|
||||
Article.objects.all().delete()
|
||||
SubProduct.objects.all().delete()
|
||||
MainProduct.objects.all().delete()
|
||||
FAQEntry.objects.all().delete()
|
||||
DownloadItem.objects.all().delete()
|
||||
HomepageSectionItem.objects.all().delete()
|
||||
HomepageSection.objects.all().delete()
|
||||
HeroSection.objects.all().delete()
|
||||
|
||||
main_product = self._seed_products()
|
||||
self._seed_faq()
|
||||
self._seed_downloads()
|
||||
self._seed_homepage_sections()
|
||||
self._seed_about_sections()
|
||||
self._seed_product_videos(main_product)
|
||||
@@ -617,18 +522,6 @@ class Command(BaseCommand):
|
||||
self._update_instance(article_section, section_data)
|
||||
return seeded_main_product
|
||||
|
||||
def _seed_faq(self):
|
||||
for entry_data in FAQ_ENTRIES:
|
||||
faq, created = FAQEntry.objects.get_or_create(
|
||||
question=entry_data["question"],
|
||||
defaults=entry_data,
|
||||
)
|
||||
if not created:
|
||||
self._update_instance(faq, entry_data)
|
||||
|
||||
action = "Created" if created else "Updated"
|
||||
self.stdout.write(f" {action} FAQ: {faq.question[:60]}...")
|
||||
|
||||
def _seed_homepage_sections(self):
|
||||
for section_data in HOMEPAGE_SECTIONS:
|
||||
items_data = section_data["items"]
|
||||
@@ -711,19 +604,6 @@ class Command(BaseCommand):
|
||||
if not created:
|
||||
self._update_instance(video, data)
|
||||
|
||||
def _seed_downloads(self):
|
||||
for item_data in DOWNLOAD_ITEMS:
|
||||
item, created = DownloadItem.objects.get_or_create(
|
||||
name=item_data["name"],
|
||||
platform=item_data["platform"],
|
||||
defaults=item_data,
|
||||
)
|
||||
if not created:
|
||||
self._update_instance(item, item_data)
|
||||
|
||||
action = "Created" if created else "Updated"
|
||||
self.stdout.write(f" {action} download: {item}")
|
||||
|
||||
def _seed_hero(self):
|
||||
data = {
|
||||
"badge": "Communication-first medical imaging",
|
||||
@@ -735,7 +615,7 @@ class Command(BaseCommand):
|
||||
"document analysis choices, and communicate results with clearer context."
|
||||
),
|
||||
"primary_cta_text": "Explore com2care",
|
||||
"primary_cta_url": "/products/",
|
||||
"primary_cta_url": "/projects/",
|
||||
"secondary_cta_text": "Our Mission",
|
||||
"secondary_cta_url": "/about/",
|
||||
"image_alt": "A multidisciplinary care team collaborating around medical imaging",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Generated by Django 5.2.16 on 2026-09-23 22:42
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0004_alter_sitebranding_hero_logo_alt_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='accent_color',
|
||||
field=models.CharField(default='#22d3ee', max_length=7, validators=[django.core.validators.RegexValidator(message='Enter a six-digit hex colour such as #4f8ef7.', regex='^#[0-9A-Fa-f]{6}$')]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='background_color',
|
||||
field=models.CharField(default='#070713', max_length=7, validators=[django.core.validators.RegexValidator(message='Enter a six-digit hex colour such as #4f8ef7.', regex='^#[0-9A-Fa-f]{6}$')]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='meta_description',
|
||||
field=models.CharField(blank=True, default='Communication to Care — Advanced Medical Imaging and Radiomics Solutions', max_length=300),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='muted_text_color',
|
||||
field=models.CharField(default='#a6abc2', max_length=7, validators=[django.core.validators.RegexValidator(message='Enter a six-digit hex colour such as #4f8ef7.', regex='^#[0-9A-Fa-f]{6}$')]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='primary_color',
|
||||
field=models.CharField(default='#4f8ef7', max_length=7, validators=[django.core.validators.RegexValidator(message='Enter a six-digit hex colour such as #4f8ef7.', regex='^#[0-9A-Fa-f]{6}$')]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='secondary_color',
|
||||
field=models.CharField(default='#a78bfa', max_length=7, validators=[django.core.validators.RegexValidator(message='Enter a six-digit hex colour such as #4f8ef7.', regex='^#[0-9A-Fa-f]{6}$')]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='short_name',
|
||||
field=models.CharField(default='com2care', help_text='Compact name used in the navigation bar.', max_length=60),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='site_name',
|
||||
field=models.CharField(default='Communication to Care', max_length=120),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='surface_color',
|
||||
field=models.CharField(default='#10102a', max_length=7, validators=[django.core.validators.RegexValidator(message='Enter a six-digit hex colour such as #4f8ef7.', regex='^#[0-9A-Fa-f]{6}$')]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='tagline',
|
||||
field=models.CharField(blank=True, default='Turning complex medical-imaging evidence into clearer, reproducible research conversations.', max_length=300),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='text_color',
|
||||
field=models.CharField(default='#eef2ff', max_length=7, validators=[django.core.validators.RegexValidator(message='Enter a six-digit hex colour such as #4f8ef7.', regex='^#[0-9A-Fa-f]{6}$')]),
|
||||
),
|
||||
]
|
||||
+64
-2
@@ -1,8 +1,12 @@
|
||||
from django.core.validators import FileExtensionValidator
|
||||
from django.core.validators import FileExtensionValidator, RegexValidator
|
||||
from django.db import models
|
||||
|
||||
|
||||
class SiteBranding(models.Model):
|
||||
hex_color_validator = RegexValidator(
|
||||
regex=r"^#[0-9A-Fa-f]{6}$",
|
||||
message="Enter a six-digit hex colour such as #4f8ef7.",
|
||||
)
|
||||
OBJECT_FIT_COVER = "cover"
|
||||
OBJECT_FIT_CONTAIN = "contain"
|
||||
OBJECT_FIT_CHOICES = [
|
||||
@@ -10,6 +14,23 @@ class SiteBranding(models.Model):
|
||||
(OBJECT_FIT_CONTAIN, "Contain (fit inside square)"),
|
||||
]
|
||||
|
||||
site_name = models.CharField(max_length=120, default="Communication to Care")
|
||||
short_name = models.CharField(
|
||||
max_length=60,
|
||||
default="com2care",
|
||||
help_text="Compact name used in the navigation bar.",
|
||||
)
|
||||
tagline = models.CharField(
|
||||
max_length=300,
|
||||
default="Turning complex medical-imaging evidence into clearer, reproducible research conversations.",
|
||||
blank=True,
|
||||
)
|
||||
meta_description = models.CharField(
|
||||
max_length=300,
|
||||
default="Communication to Care — Advanced Medical Imaging and Radiomics Solutions",
|
||||
blank=True,
|
||||
)
|
||||
|
||||
icon = models.ImageField(
|
||||
upload_to="branding/",
|
||||
blank=True,
|
||||
@@ -77,13 +98,34 @@ class SiteBranding(models.Model):
|
||||
choices=OBJECT_FIT_CHOICES,
|
||||
default=OBJECT_FIT_COVER,
|
||||
)
|
||||
background_color = models.CharField(
|
||||
max_length=7, default="#070713", validators=[hex_color_validator]
|
||||
)
|
||||
surface_color = models.CharField(
|
||||
max_length=7, default="#10102a", validators=[hex_color_validator]
|
||||
)
|
||||
primary_color = models.CharField(
|
||||
max_length=7, default="#4f8ef7", validators=[hex_color_validator]
|
||||
)
|
||||
secondary_color = models.CharField(
|
||||
max_length=7, default="#a78bfa", validators=[hex_color_validator]
|
||||
)
|
||||
accent_color = models.CharField(
|
||||
max_length=7, default="#22d3ee", validators=[hex_color_validator]
|
||||
)
|
||||
text_color = models.CharField(
|
||||
max_length=7, default="#eef2ff", validators=[hex_color_validator]
|
||||
)
|
||||
muted_text_color = models.CharField(
|
||||
max_length=7, default="#a6abc2", validators=[hex_color_validator]
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Site Branding"
|
||||
verbose_name_plural = "Site Branding"
|
||||
|
||||
def __str__(self):
|
||||
return "Site Branding"
|
||||
return self.site_name
|
||||
|
||||
@classmethod
|
||||
def load(cls):
|
||||
@@ -108,6 +150,26 @@ class SiteBranding(models.Model):
|
||||
def footer_icon_style(self):
|
||||
return self.icon_style(self.footer_icon_size)
|
||||
|
||||
@property
|
||||
def theme_style(self):
|
||||
return ";".join(
|
||||
(
|
||||
f"--bg-primary:{self.background_color}",
|
||||
f"--bg-secondary:{self.background_color}",
|
||||
f"--bg-surface:{self.surface_color}",
|
||||
f"--accent-blue:{self.primary_color}",
|
||||
f"--accent-blue-light:{self.primary_color}",
|
||||
f"--accent-purple:{self.secondary_color}",
|
||||
f"--accent-cyan:{self.accent_color}",
|
||||
f"--text-primary:{self.text_color}",
|
||||
f"--text-secondary:{self.muted_text_color}",
|
||||
f"--text-muted:{self.muted_text_color}",
|
||||
f"--text-link:{self.primary_color}",
|
||||
f"--gradient-brand:linear-gradient(135deg,{self.primary_color} 0%,{self.secondary_color} 100%)",
|
||||
f"--gradient-text:linear-gradient(135deg,{self.primary_color} 0%,{self.secondary_color} 50%,{self.accent_color} 100%)",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class SiteContact(models.Model):
|
||||
support_email = models.EmailField(
|
||||
|
||||
@@ -59,3 +59,16 @@ class SiteBrandingTemplateTests(TestCase):
|
||||
|
||||
self.assertContains(response, "images/com2care-mark.svg")
|
||||
self.assertContains(response, "images/com2care-care-team.jpg")
|
||||
|
||||
def test_site_identity_and_theme_are_admin_configurable(self):
|
||||
branding = SiteBranding.load()
|
||||
branding.site_name = "Care Research Network"
|
||||
branding.short_name = "CRN"
|
||||
branding.primary_color = "#123456"
|
||||
branding.save()
|
||||
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
|
||||
self.assertContains(response, "Care Research Network")
|
||||
self.assertContains(response, ">CRN</span>")
|
||||
self.assertContains(response, "--accent-blue:#123456")
|
||||
|
||||
+1
-1
@@ -117,7 +117,7 @@ class VideoBlockMixin(models.Model):
|
||||
)
|
||||
video_styled_background = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Glass panel with ambient glow (similar to the Downloads section).",
|
||||
help_text="Show the video in a glass panel with an ambient glow.",
|
||||
)
|
||||
description_format = models.CharField(
|
||||
max_length=20,
|
||||
|
||||
+16
-28
@@ -9,13 +9,12 @@ from apps.core.video import VIDEO_ADMIN_FIELDSET
|
||||
from .models import (
|
||||
AboutSection,
|
||||
AboutSectionItem,
|
||||
BlogPost,
|
||||
ContactSubmission,
|
||||
ContactSubmissionAttachment,
|
||||
CustomPage,
|
||||
CustomPageSection,
|
||||
CustomPageSectionItem,
|
||||
DownloadItem,
|
||||
FAQEntry,
|
||||
HeroSection,
|
||||
HomepageSection,
|
||||
HomepageSectionItem,
|
||||
@@ -74,7 +73,6 @@ class HomepageSectionAdmin(admin.ModelAdmin):
|
||||
def video_preview(self, obj):
|
||||
return video_admin_preview(obj)
|
||||
|
||||
|
||||
class AboutSectionItemInline(admin.TabularInline):
|
||||
model = AboutSectionItem
|
||||
extra = 1
|
||||
@@ -154,19 +152,6 @@ class ContactSubmissionAdmin(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
|
||||
@admin.register(FAQEntry)
|
||||
class FAQEntryAdmin(admin.ModelAdmin):
|
||||
list_display = ("question", "order", "is_active", "created_at")
|
||||
list_filter = ("is_active",)
|
||||
search_fields = ("question", "answer")
|
||||
list_editable = ("order", "is_active")
|
||||
fieldsets = (
|
||||
(None, {"fields": ("question",)}),
|
||||
("Answer", {"fields": ("answer_format", "answer")}),
|
||||
("Settings", {"fields": ("order", "is_active")}),
|
||||
)
|
||||
|
||||
|
||||
class CustomPageSectionItemInline(admin.TabularInline):
|
||||
model = CustomPageSectionItem
|
||||
extra = 1
|
||||
@@ -290,6 +275,21 @@ class CustomPageAdmin(admin.ModelAdmin):
|
||||
admin.site.register(CustomPageSection, CustomPageSectionAdmin)
|
||||
|
||||
|
||||
@admin.register(BlogPost)
|
||||
class BlogPostAdmin(admin.ModelAdmin):
|
||||
list_display = ("title", "status", "published_at", "updated_at")
|
||||
list_filter = ("status", "published_at")
|
||||
search_fields = ("title", "excerpt", "content", "author_name")
|
||||
prepopulated_fields = {"slug": ("title",)}
|
||||
date_hierarchy = "published_at"
|
||||
fieldsets = (
|
||||
(None, {"fields": ("title", "slug", "excerpt")}),
|
||||
("Article", {"fields": ("content_format", "content")}),
|
||||
("Cover image", {"fields": ("cover_image", "cover_image_alt")}),
|
||||
("Publishing", {"fields": ("status", "published_at", "author_name")}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(PageVideo)
|
||||
class PageVideoAdmin(admin.ModelAdmin):
|
||||
list_display = ("page", "title", "video_source", "video_size", "order", "is_active")
|
||||
@@ -306,15 +306,3 @@ class PageVideoAdmin(admin.ModelAdmin):
|
||||
@admin.display(description="Preview")
|
||||
def video_preview(self, obj):
|
||||
return video_admin_preview(obj)
|
||||
|
||||
|
||||
@admin.register(DownloadItem)
|
||||
class DownloadItemAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "platform", "version", "is_active", "order")
|
||||
list_filter = ("platform", "is_active")
|
||||
search_fields = ("name", "description")
|
||||
list_editable = ("order", "is_active")
|
||||
fieldsets = (
|
||||
(None, {"fields": ("name", "platform", "version", "download_url", "description")}),
|
||||
("Settings", {"fields": ("order", "is_active")}),
|
||||
)
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
# Generated by Django 5.2.16 on 2026-09-23 22:42
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pages', '0018_alter_herosection_title'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BlogPost',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=240)),
|
||||
('slug', models.SlugField(blank=True, max_length=240, unique=True)),
|
||||
('excerpt', models.CharField(blank=True, help_text='Short summary shown on the blog listing and in search previews.', max_length=400)),
|
||||
('content', models.TextField()),
|
||||
('content_format', models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='markdown', max_length=20)),
|
||||
('cover_image', models.ImageField(blank=True, null=True, upload_to='blog/')),
|
||||
('cover_image_alt', models.CharField(blank=True, max_length=240)),
|
||||
('author_name', models.CharField(blank=True, max_length=120)),
|
||||
('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published')], default='draft', max_length=20)),
|
||||
('published_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Blog post',
|
||||
'verbose_name_plural': 'Blog posts',
|
||||
'ordering': ['-published_at', '-pk'],
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='aboutsection',
|
||||
name='video_styled_background',
|
||||
field=models.BooleanField(default=False, help_text='Show the video in a glass panel with an ambient glow.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='custompagesection',
|
||||
name='section_type',
|
||||
field=models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('features', 'Features Grid'), ('screenshots', 'Screenshots Gallery'), ('products', 'Projects Grid'), ('products_catalog', 'Projects with sub-projects'), ('problems', 'Problems / Value Proposition'), ('supporters', 'Supporters'), ('about_strip', 'About Strip'), ('video', 'Video')], default='custom', max_length=30),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='custompagesection',
|
||||
name='video_styled_background',
|
||||
field=models.BooleanField(default=False, help_text='Show the video in a glass panel with an ambient glow.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='homepagesection',
|
||||
name='section_type',
|
||||
field=models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Projects'), ('products_catalog', 'Projects with sub-projects'), ('problems', 'Problems / Value Proposition'), ('about_strip', 'About Strip'), ('supporters', 'Supporters'), ('video', 'Video')], max_length=30),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='homepagesection',
|
||||
name='video_styled_background',
|
||||
field=models.BooleanField(default=False, help_text='Show the video in a glass panel with an ambient glow.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='pagevideo',
|
||||
name='page',
|
||||
field=models.CharField(choices=[('contact', 'Become a Member')], max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='pagevideo',
|
||||
name='video_styled_background',
|
||||
field=models.BooleanField(default=False, help_text='Show the video in a glass panel with an ambient glow.'),
|
||||
),
|
||||
]
|
||||
+65
-9
@@ -1,5 +1,7 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
|
||||
from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_MARKDOWN, FORMAT_PLAIN, render_content
|
||||
@@ -13,8 +15,10 @@ RESERVED_PAGE_SLUGS = frozenset({
|
||||
"admin",
|
||||
"about",
|
||||
"contact",
|
||||
"blog",
|
||||
"faq",
|
||||
"home",
|
||||
"projects",
|
||||
"products",
|
||||
"static",
|
||||
"media",
|
||||
@@ -55,8 +59,8 @@ class HomepageSection(VideoBlockMixin, models.Model):
|
||||
TYPE_CHOICES = [
|
||||
(TYPE_FEATURES, "Features"),
|
||||
(TYPE_SCREENSHOTS, "Screenshots Gallery"),
|
||||
(TYPE_PRODUCTS, "Products"),
|
||||
(TYPE_PRODUCTS_CATALOG, "Products with Sub-products"),
|
||||
(TYPE_PRODUCTS, "Projects"),
|
||||
(TYPE_PRODUCTS_CATALOG, "Projects with sub-projects"),
|
||||
(TYPE_PROBLEMS, "Problems / Value Proposition"),
|
||||
(TYPE_ABOUT_STRIP, "About Strip"),
|
||||
(TYPE_SUPPORTERS, "Supporters"),
|
||||
@@ -310,6 +314,62 @@ class CustomPage(models.Model):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class BlogPost(models.Model):
|
||||
STATUS_DRAFT = "draft"
|
||||
STATUS_PUBLISHED = "published"
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_DRAFT, "Draft"),
|
||||
(STATUS_PUBLISHED, "Published"),
|
||||
]
|
||||
|
||||
title = models.CharField(max_length=240)
|
||||
slug = models.SlugField(max_length=240, unique=True, blank=True)
|
||||
excerpt = models.CharField(
|
||||
max_length=400,
|
||||
blank=True,
|
||||
help_text="Short summary shown on the blog listing and in search previews.",
|
||||
)
|
||||
content = models.TextField()
|
||||
content_format = models.CharField(
|
||||
max_length=20,
|
||||
choices=CONTENT_FORMAT_CHOICES,
|
||||
default=FORMAT_MARKDOWN,
|
||||
)
|
||||
cover_image = models.ImageField(upload_to="blog/", blank=True, null=True)
|
||||
cover_image_alt = models.CharField(max_length=240, blank=True)
|
||||
author_name = models.CharField(max_length=120, blank=True)
|
||||
status = models.CharField(
|
||||
max_length=20, choices=STATUS_CHOICES, default=STATUS_DRAFT
|
||||
)
|
||||
published_at = models.DateTimeField(default=timezone.now)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-published_at", "-pk"]
|
||||
verbose_name = "Blog post"
|
||||
verbose_name_plural = "Blog posts"
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@property
|
||||
def rendered_content(self):
|
||||
return render_content(self.content, self.content_format)
|
||||
|
||||
@property
|
||||
def is_public(self):
|
||||
return self.status == self.STATUS_PUBLISHED and self.published_at <= timezone.now()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.title)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("pages:blog_detail", kwargs={"slug": self.slug})
|
||||
|
||||
|
||||
class CustomPageSection(VideoBlockMixin, models.Model):
|
||||
TYPE_HERO = "hero"
|
||||
TYPE_INTRO = "intro"
|
||||
@@ -323,7 +383,6 @@ class CustomPageSection(VideoBlockMixin, models.Model):
|
||||
TYPE_PROBLEMS = "problems"
|
||||
TYPE_SUPPORTERS = "supporters"
|
||||
TYPE_ABOUT_STRIP = "about_strip"
|
||||
TYPE_FAQ = "faq"
|
||||
TYPE_VIDEO = "video"
|
||||
|
||||
TYPE_CHOICES = [
|
||||
@@ -334,12 +393,11 @@ class CustomPageSection(VideoBlockMixin, models.Model):
|
||||
(TYPE_CUSTOM, "Custom Content"),
|
||||
(TYPE_FEATURES, "Features Grid"),
|
||||
(TYPE_SCREENSHOTS, "Screenshots Gallery"),
|
||||
(TYPE_PRODUCTS, "Products Grid"),
|
||||
(TYPE_PRODUCTS_CATALOG, "Products with Sub-products"),
|
||||
(TYPE_PRODUCTS, "Projects Grid"),
|
||||
(TYPE_PRODUCTS_CATALOG, "Projects with sub-projects"),
|
||||
(TYPE_PROBLEMS, "Problems / Value Proposition"),
|
||||
(TYPE_SUPPORTERS, "Supporters"),
|
||||
(TYPE_ABOUT_STRIP, "About Strip"),
|
||||
(TYPE_FAQ, "FAQ Accordion"),
|
||||
(TYPE_VIDEO, "Video"),
|
||||
]
|
||||
|
||||
@@ -377,11 +435,9 @@ class CustomPageSection(VideoBlockMixin, models.Model):
|
||||
|
||||
class PageVideo(VideoBlockMixin, models.Model):
|
||||
PAGE_CONTACT = "contact"
|
||||
PAGE_FAQ = "faq"
|
||||
|
||||
PAGE_CHOICES = [
|
||||
(PAGE_CONTACT, "Contact"),
|
||||
(PAGE_FAQ, "FAQ"),
|
||||
(PAGE_CONTACT, "Become a Member"),
|
||||
]
|
||||
|
||||
page = models.CharField(max_length=20, choices=PAGE_CHOICES)
|
||||
|
||||
@@ -103,37 +103,37 @@ class PageVideoTest(TestCase):
|
||||
self.assertContains(response, "Intro")
|
||||
self.assertContains(response, "youtube-nocookie.com/embed/dQw4w9WgXcQ")
|
||||
|
||||
def test_faq_page_video(self):
|
||||
def test_membership_page_video(self):
|
||||
PageVideo.objects.create(
|
||||
page=PageVideo.PAGE_FAQ,
|
||||
page=PageVideo.PAGE_CONTACT,
|
||||
title="Tutorial",
|
||||
video_source="youtube",
|
||||
video_url="https://youtu.be/dQw4w9WgXcQ",
|
||||
is_active=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:faq"))
|
||||
response = self.client.get(reverse("pages:contact"))
|
||||
self.assertContains(response, "Tutorial")
|
||||
|
||||
def test_page_shows_three_video_preview_and_archive_link(self):
|
||||
self._create_page_videos(PageVideo.PAGE_FAQ, 8)
|
||||
self._create_page_videos(PageVideo.PAGE_CONTACT, 8)
|
||||
|
||||
response = self.client.get(reverse("pages:faq"))
|
||||
response = self.client.get(reverse("pages:contact"))
|
||||
|
||||
self.assertEqual(response.content.count(b"youtube-nocookie.com/embed"), 3)
|
||||
self.assertContains(
|
||||
response,
|
||||
reverse(
|
||||
"pages:video_archive",
|
||||
kwargs={"library": PageVideo.PAGE_FAQ},
|
||||
kwargs={"library": PageVideo.PAGE_CONTACT},
|
||||
),
|
||||
)
|
||||
self.assertContains(response, "More videos")
|
||||
|
||||
def test_page_video_archive_is_paginated(self):
|
||||
self._create_page_videos(PageVideo.PAGE_FAQ, 8)
|
||||
self._create_page_videos(PageVideo.PAGE_CONTACT, 8)
|
||||
archive_url = reverse(
|
||||
"pages:video_archive",
|
||||
kwargs={"library": PageVideo.PAGE_FAQ},
|
||||
kwargs={"library": PageVideo.PAGE_CONTACT},
|
||||
)
|
||||
|
||||
first_page = self.client.get(archive_url)
|
||||
@@ -248,7 +248,7 @@ class VideoDescriptionFormatTest(TestCase):
|
||||
|
||||
def test_markdown_description_renders(self):
|
||||
PageVideo.objects.create(
|
||||
page=PageVideo.PAGE_FAQ,
|
||||
page=PageVideo.PAGE_CONTACT,
|
||||
title="Guide",
|
||||
description="**Bold** intro",
|
||||
description_format="markdown",
|
||||
@@ -256,5 +256,5 @@ class VideoDescriptionFormatTest(TestCase):
|
||||
video_url="https://youtu.be/dQw4w9WgXcQ",
|
||||
is_active=True,
|
||||
)
|
||||
response = self.client.get(reverse("pages:faq"))
|
||||
response = self.client.get(reverse("pages:contact"))
|
||||
self.assertContains(response, "<strong>Bold</strong>")
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.pages.models import FAQEntry
|
||||
from apps.pages.models import BlogPost, HomepageSection
|
||||
|
||||
|
||||
class HomeViewTest(TestCase):
|
||||
@@ -13,6 +14,16 @@ class HomeViewTest(TestCase):
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
self.assertTemplateUsed(response, "pages/home.html")
|
||||
|
||||
def test_section_description_renders_markdown_and_html(self):
|
||||
HomepageSection.objects.create(
|
||||
section_type=HomepageSection.TYPE_FEATURES,
|
||||
title="Rich section",
|
||||
description="**Formatted** content",
|
||||
description_format="markdown",
|
||||
)
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
self.assertContains(response, "<strong>Formatted</strong>")
|
||||
|
||||
|
||||
class AboutViewTest(TestCase):
|
||||
def test_about_returns_200(self):
|
||||
@@ -24,34 +35,30 @@ class AboutViewTest(TestCase):
|
||||
self.assertTemplateUsed(response, "pages/about.html")
|
||||
|
||||
|
||||
class FAQViewTest(TestCase):
|
||||
class BlogViewTest(TestCase):
|
||||
def setUp(self):
|
||||
FAQEntry.objects.create(
|
||||
question="What is the license?",
|
||||
answer="It is CC BY-NC-SA.",
|
||||
order=1,
|
||||
is_active=True,
|
||||
)
|
||||
FAQEntry.objects.create(
|
||||
question="Hidden question",
|
||||
answer="Hidden answer",
|
||||
order=2,
|
||||
is_active=False,
|
||||
self.post = BlogPost.objects.create(
|
||||
title="Research update",
|
||||
slug="research-update",
|
||||
excerpt="A short summary.",
|
||||
content="## Results\n\n**Promising** findings.",
|
||||
content_format="markdown",
|
||||
status=BlogPost.STATUS_PUBLISHED,
|
||||
published_at=timezone.now(),
|
||||
)
|
||||
|
||||
def test_faq_returns_200(self):
|
||||
response = self.client.get(reverse("pages:faq"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
def test_blog_list_and_detail_render_published_post(self):
|
||||
listing = self.client.get(reverse("pages:blog_list"))
|
||||
detail = self.client.get(self.post.get_absolute_url())
|
||||
self.assertContains(listing, self.post.title)
|
||||
self.assertContains(detail, "<h2>Results</h2>")
|
||||
self.assertContains(detail, "<strong>Promising</strong>")
|
||||
|
||||
def test_faq_uses_correct_template(self):
|
||||
response = self.client.get(reverse("pages:faq"))
|
||||
self.assertTemplateUsed(response, "pages/faq.html")
|
||||
|
||||
def test_faq_only_shows_active_entries(self):
|
||||
response = self.client.get(reverse("pages:faq"))
|
||||
entries = response.context["faq_entries"]
|
||||
self.assertEqual(entries.count(), 1)
|
||||
self.assertEqual(entries.first().question, "What is the license?")
|
||||
def test_draft_post_is_not_public(self):
|
||||
self.post.status = BlogPost.STATUS_DRAFT
|
||||
self.post.save(update_fields=["status"])
|
||||
self.assertNotContains(self.client.get(reverse("pages:blog_list")), self.post.title)
|
||||
self.assertEqual(self.client.get(self.post.get_absolute_url()).status_code, 404)
|
||||
|
||||
|
||||
class ContactViewTest(TestCase):
|
||||
@@ -69,7 +76,7 @@ class NavigationContextTest(TestCase):
|
||||
urls = [
|
||||
reverse("pages:home"),
|
||||
reverse("pages:about"),
|
||||
reverse("pages:faq"),
|
||||
reverse("pages:blog_list"),
|
||||
reverse("pages:contact"),
|
||||
reverse("products:overview"),
|
||||
]
|
||||
@@ -85,7 +92,7 @@ class NavigationContextTest(TestCase):
|
||||
urls = [
|
||||
reverse("pages:home"),
|
||||
reverse("pages:about"),
|
||||
reverse("pages:faq"),
|
||||
reverse("pages:blog_list"),
|
||||
reverse("pages:contact"),
|
||||
]
|
||||
for url in urls:
|
||||
|
||||
+2
-1
@@ -7,8 +7,9 @@ app_name = "pages"
|
||||
urlpatterns = [
|
||||
path("", views.HomeView.as_view(), name="home"),
|
||||
path("about/", views.AboutView.as_view(), name="about"),
|
||||
path("faq/", views.FAQView.as_view(), name="faq"),
|
||||
path("contact/", views.ContactView.as_view(), name="contact"),
|
||||
path("blog/", views.BlogPostListView.as_view(), name="blog_list"),
|
||||
path("blog/<slug:slug>/", views.BlogPostDetailView.as_view(), name="blog_detail"),
|
||||
path(
|
||||
"videos/<slug:library>/",
|
||||
views.PageVideoArchiveView.as_view(),
|
||||
|
||||
+27
-31
@@ -4,6 +4,7 @@ from django.db.models import Prefetch
|
||||
from django.http import Http404, JsonResponse
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.views import View
|
||||
from django.views.generic import DetailView, ListView, TemplateView
|
||||
|
||||
@@ -12,10 +13,10 @@ from apps.products.models import MainProduct, SubProduct
|
||||
from .forms import ContactForm
|
||||
from .models import (
|
||||
AboutSection,
|
||||
BlogPost,
|
||||
ContactSubmission,
|
||||
ContactSubmissionAttachment,
|
||||
CustomPage,
|
||||
FAQEntry,
|
||||
HeroSection,
|
||||
HomepageSection,
|
||||
PageVideo,
|
||||
@@ -79,31 +80,6 @@ class AboutView(TemplateView):
|
||||
return ctx
|
||||
|
||||
|
||||
class FAQView(ListView):
|
||||
model = FAQEntry
|
||||
template_name = "pages/faq.html"
|
||||
context_object_name = "faq_entries"
|
||||
queryset = FAQEntry.objects.filter(is_active=True)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
videos = PageVideo.objects.filter(
|
||||
page=PageVideo.PAGE_FAQ,
|
||||
is_active=True,
|
||||
).order_by("order", "pk")
|
||||
preview = _video_preview_context(
|
||||
videos,
|
||||
reverse(
|
||||
"pages:video_archive",
|
||||
kwargs={"library": PageVideo.PAGE_FAQ},
|
||||
),
|
||||
)
|
||||
ctx["faq_videos"] = preview["videos"]
|
||||
ctx["videos_total_count"] = preview["videos_total_count"]
|
||||
ctx["videos_archive_url"] = preview["videos_archive_url"]
|
||||
return ctx
|
||||
|
||||
|
||||
class ContactView(View):
|
||||
template_name = "pages/contact.html"
|
||||
|
||||
@@ -198,6 +174,31 @@ class CustomPageView(DetailView):
|
||||
return ctx
|
||||
|
||||
|
||||
class BlogPostListView(ListView):
|
||||
model = BlogPost
|
||||
template_name = "pages/blog_list.html"
|
||||
context_object_name = "posts"
|
||||
paginate_by = 9
|
||||
|
||||
def get_queryset(self):
|
||||
return BlogPost.objects.filter(
|
||||
status=BlogPost.STATUS_PUBLISHED,
|
||||
published_at__lte=timezone.now(),
|
||||
)
|
||||
|
||||
|
||||
class BlogPostDetailView(DetailView):
|
||||
model = BlogPost
|
||||
template_name = "pages/blog_detail.html"
|
||||
context_object_name = "post"
|
||||
|
||||
def get_queryset(self):
|
||||
return BlogPost.objects.filter(
|
||||
status=BlogPost.STATUS_PUBLISHED,
|
||||
published_at__lte=timezone.now(),
|
||||
)
|
||||
|
||||
|
||||
class PageVideoArchiveView(ListView):
|
||||
model = PageVideo
|
||||
template_name = "videos/archive.html"
|
||||
@@ -210,11 +211,6 @@ class PageVideoArchiveView(ListView):
|
||||
"Guides and updates from the Communication to Care team.",
|
||||
"pages:contact",
|
||||
),
|
||||
PageVideo.PAGE_FAQ: (
|
||||
"FAQ videos",
|
||||
"Video answers to common questions.",
|
||||
"pages:faq",
|
||||
),
|
||||
}
|
||||
|
||||
def get_page_config(self):
|
||||
|
||||
+5
-122
@@ -3,7 +3,7 @@ from django.contrib import admin
|
||||
from apps.core.admin_video import video_admin_preview
|
||||
from apps.core.video import VIDEO_ADMIN_FIELDSET
|
||||
|
||||
from .models import Article, ArticleCitation, ArticleSection, MainProduct, ProductVideo, SubProduct, SubProductVersion
|
||||
from .models import Article, ArticleCitation, ArticleSection, MainProduct, ProductVideo, SubProduct
|
||||
|
||||
|
||||
class ArticleCitationInline(admin.TabularInline):
|
||||
@@ -38,41 +38,6 @@ class MainProductArticleInline(admin.StackedInline):
|
||||
show_change_link = True
|
||||
|
||||
|
||||
RELEASE_VERSION_INLINE_FIELDS = (
|
||||
"version",
|
||||
"release_channel",
|
||||
"channel_label",
|
||||
"is_featured",
|
||||
"show_on_product_page",
|
||||
"windows_download_url",
|
||||
"macos_download_url",
|
||||
"linux_download_url",
|
||||
"source_code_url",
|
||||
"package_resource_url",
|
||||
"release_notes",
|
||||
"is_active",
|
||||
"order",
|
||||
)
|
||||
|
||||
|
||||
class SubProductVersionInline(admin.TabularInline):
|
||||
model = SubProductVersion
|
||||
fk_name = "sub_product"
|
||||
extra = 0
|
||||
ordering = ("order", "version")
|
||||
fields = RELEASE_VERSION_INLINE_FIELDS
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class MainProductVersionInline(admin.TabularInline):
|
||||
model = SubProductVersion
|
||||
fk_name = "main_product"
|
||||
extra = 0
|
||||
ordering = ("order", "version")
|
||||
fields = RELEASE_VERSION_INLINE_FIELDS
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class ProductVideoInline(admin.StackedInline):
|
||||
model = ProductVideo
|
||||
fk_name = "main_product"
|
||||
@@ -98,7 +63,7 @@ class ProductVideoInline(admin.StackedInline):
|
||||
class SubProductInline(admin.StackedInline):
|
||||
model = SubProduct
|
||||
extra = 0
|
||||
fields = ("name", "slug", "distribution", "short_description", "image", "logo", "show_on_homepage", "homepage_order", "order", "is_active")
|
||||
fields = ("name", "slug", "short_description", "image", "logo", "show_on_homepage", "homepage_order", "order", "is_active")
|
||||
ordering = ("order",)
|
||||
show_change_link = True
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
@@ -111,7 +76,7 @@ class MainProductAdmin(admin.ModelAdmin):
|
||||
search_fields = ("name", "description")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
|
||||
inlines = [MainProductArticleInline, ProductVideoInline, MainProductVersionInline, SubProductInline]
|
||||
inlines = [MainProductArticleInline, ProductVideoInline, SubProductInline]
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
@@ -120,11 +85,6 @@ class MainProductAdmin(admin.ModelAdmin):
|
||||
"name",
|
||||
"slug",
|
||||
"short_description",
|
||||
"distribution",
|
||||
"package_resource_button_text",
|
||||
"package_source_button_text",
|
||||
"downloads_section_link_text",
|
||||
"downloads_section_link_url",
|
||||
)
|
||||
},
|
||||
),
|
||||
@@ -162,19 +122,18 @@ class SubProductAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"main_product",
|
||||
"distribution",
|
||||
"show_on_homepage",
|
||||
"homepage_order",
|
||||
"order",
|
||||
"is_active",
|
||||
"created_at",
|
||||
)
|
||||
list_filter = ("is_active", "distribution", "main_product", "show_on_homepage")
|
||||
list_filter = ("is_active", "main_product", "show_on_homepage")
|
||||
search_fields = ("name", "description", "main_product__name")
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
|
||||
raw_id_fields = ("main_product",)
|
||||
inlines = [SubProductArticleInline, SubProductVideoInline, SubProductVersionInline]
|
||||
inlines = [SubProductArticleInline, SubProductVideoInline]
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
@@ -183,12 +142,7 @@ class SubProductAdmin(admin.ModelAdmin):
|
||||
"main_product",
|
||||
"name",
|
||||
"slug",
|
||||
"distribution",
|
||||
"short_description",
|
||||
"package_resource_button_text",
|
||||
"package_source_button_text",
|
||||
"downloads_section_link_text",
|
||||
"downloads_section_link_url",
|
||||
)
|
||||
},
|
||||
),
|
||||
@@ -261,74 +215,3 @@ class ArticleSectionAdmin(admin.ModelAdmin):
|
||||
("Content", {"fields": ("value_format", "value")}),
|
||||
("Settings", {"fields": ("order",)}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(SubProductVersion)
|
||||
class SubProductVersionAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"parent",
|
||||
"version",
|
||||
"release_channel",
|
||||
"channel_label",
|
||||
"is_featured",
|
||||
"show_on_product_page",
|
||||
"is_active",
|
||||
"order",
|
||||
)
|
||||
list_filter = ("is_active", "release_channel", "is_featured", "show_on_product_page", "main_product", "sub_product__main_product")
|
||||
search_fields = ("main_product__name", "sub_product__name", "version")
|
||||
list_editable = ("is_active", "order")
|
||||
raw_id_fields = ("main_product", "sub_product")
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"description": (
|
||||
"Set a preset channel badge (Stable, Beta, Previous, etc.) or write a custom "
|
||||
"label. Mark one release as Primary (featured) for the main download block. "
|
||||
"Enable “Show on product page” for additional inline channels such as beta or "
|
||||
"previous versions."
|
||||
),
|
||||
"fields": (
|
||||
"main_product",
|
||||
"sub_product",
|
||||
"version",
|
||||
"release_channel",
|
||||
"channel_label",
|
||||
"is_featured",
|
||||
"show_on_product_page",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Installable downloads",
|
||||
{
|
||||
"description": (
|
||||
"For each platform, set an external URL, upload a file, both, or neither. "
|
||||
"When a URL is set it is used on the site; otherwise an uploaded file is served. "
|
||||
"To delete an uploaded file from the server, open this release, check Clear next to the file, and Save."
|
||||
),
|
||||
"fields": (
|
||||
"windows_download_url",
|
||||
"windows_download_file",
|
||||
"macos_download_url",
|
||||
"macos_download_file",
|
||||
"linux_download_url",
|
||||
"linux_download_file",
|
||||
"source_code_url",
|
||||
"source_code_file",
|
||||
),
|
||||
},
|
||||
),
|
||||
("Package link", {"fields": ("package_resource_url",)}),
|
||||
("Details", {"fields": ("release_notes",)}),
|
||||
("Settings", {"fields": ("is_active", "order")}),
|
||||
)
|
||||
|
||||
@admin.display(description="Parent")
|
||||
def parent(self, obj):
|
||||
if obj.main_product_id:
|
||||
return obj.main_product
|
||||
if obj.sub_product_id:
|
||||
return obj.sub_product
|
||||
return "—"
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.apps import AppConfig
|
||||
class ProductsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.products"
|
||||
verbose_name = "Products"
|
||||
verbose_name = "Projects"
|
||||
|
||||
def ready(self):
|
||||
from . import signals # noqa: F401
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
# Generated by Django 5.2.16 on 2026-09-23 22:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0021_release_channels'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='mainproduct',
|
||||
options={'ordering': ['order', 'name'], 'verbose_name': 'Main Project', 'verbose_name_plural': 'Main Projects'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='productvideo',
|
||||
options={'ordering': ['order', 'pk'], 'verbose_name': 'Project Video', 'verbose_name_plural': 'Project Videos'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='subproduct',
|
||||
options={'ordering': ['order', 'name'], 'verbose_name': 'Sub-project', 'verbose_name_plural': 'Sub-projects'},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='mainproduct',
|
||||
name='distribution',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='mainproduct',
|
||||
name='downloads_section_link_text',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='mainproduct',
|
||||
name='downloads_section_link_url',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='mainproduct',
|
||||
name='package_resource_button_text',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='mainproduct',
|
||||
name='package_source_button_text',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='subproduct',
|
||||
name='distribution',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='subproduct',
|
||||
name='downloads_section_link_text',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='subproduct',
|
||||
name='downloads_section_link_url',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='subproduct',
|
||||
name='package_resource_button_text',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='subproduct',
|
||||
name='package_source_button_text',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='mainproduct',
|
||||
name='homepage_order',
|
||||
field=models.PositiveIntegerField(default=0, help_text='Order within the homepage Projects section.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='mainproduct',
|
||||
name='show_on_homepage',
|
||||
field=models.BooleanField(default=False, help_text='Display this project in the homepage Projects section.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='productvideo',
|
||||
name='video_styled_background',
|
||||
field=models.BooleanField(default=False, help_text='Show the video in a glass panel with an ambient glow.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subproduct',
|
||||
name='homepage_order',
|
||||
field=models.PositiveIntegerField(default=0, help_text='Order within the homepage Projects section.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='subproduct',
|
||||
name='show_on_homepage',
|
||||
field=models.BooleanField(default=False, help_text='Display this sub-project in the homepage Projects section.'),
|
||||
),
|
||||
]
|
||||
+12
-91
@@ -30,13 +30,6 @@ INSTALLABLE_PLATFORM_SPECS = (
|
||||
("source_code_url", "Source code", None),
|
||||
)
|
||||
|
||||
DISTRIBUTION_INSTALLABLE = "installable"
|
||||
DISTRIBUTION_PACKAGE = "package"
|
||||
DISTRIBUTION_CHOICES = [
|
||||
(DISTRIBUTION_INSTALLABLE, "Installable application"),
|
||||
(DISTRIBUTION_PACKAGE, "Package (external / non-installable)"),
|
||||
]
|
||||
|
||||
RELEASE_CHANNEL_STABLE = "stable"
|
||||
RELEASE_CHANNEL_BETA = "beta"
|
||||
RELEASE_CHANNEL_RC = "rc"
|
||||
@@ -66,7 +59,6 @@ RELEASE_CHANNEL_LABELS = {
|
||||
RELEASE_CHANNEL_PREVIOUS: "Previous",
|
||||
}
|
||||
|
||||
|
||||
class MainProduct(models.Model):
|
||||
name = models.CharField(max_length=200)
|
||||
slug = models.SlugField(unique=True, blank=True)
|
||||
@@ -76,42 +68,17 @@ class MainProduct(models.Model):
|
||||
max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN
|
||||
)
|
||||
image = models.ImageField(upload_to="products/main/", blank=True, null=True)
|
||||
distribution = models.CharField(
|
||||
max_length=20,
|
||||
choices=DISTRIBUTION_CHOICES,
|
||||
default=DISTRIBUTION_INSTALLABLE,
|
||||
help_text="Only affects how releases appear on the public site.",
|
||||
)
|
||||
package_resource_button_text = models.CharField(
|
||||
max_length=100,
|
||||
default="PyPI",
|
||||
help_text="Label for the package resource button on package releases.",
|
||||
)
|
||||
package_source_button_text = models.CharField(
|
||||
max_length=100,
|
||||
default="Source",
|
||||
help_text="Label for the source code button on package releases.",
|
||||
)
|
||||
downloads_section_link_url = models.URLField(
|
||||
blank=True,
|
||||
help_text="Optional link shown in the top-right corner of the downloads section.",
|
||||
)
|
||||
downloads_section_link_text = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text="Label for the optional downloads section link.",
|
||||
)
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
show_on_homepage = models.BooleanField(default=False, help_text="Display this product in the homepage Products section.")
|
||||
homepage_order = models.PositiveIntegerField(default=0, help_text="Order within the homepage Products section.")
|
||||
show_on_homepage = models.BooleanField(default=False, help_text="Display this project in the homepage Projects section.")
|
||||
homepage_order = models.PositiveIntegerField(default=0, help_text="Order within the homepage Projects section.")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order", "name"]
|
||||
verbose_name = "Main Product"
|
||||
verbose_name_plural = "Main Products"
|
||||
verbose_name = "Main Project"
|
||||
verbose_name_plural = "Main Projects"
|
||||
|
||||
@property
|
||||
def rendered_description(self):
|
||||
@@ -128,18 +95,7 @@ class MainProduct(models.Model):
|
||||
def get_absolute_url(self):
|
||||
return reverse("products:main_product_detail", kwargs={"main_slug": self.slug})
|
||||
|
||||
def get_versions_archive_url(self):
|
||||
return reverse(
|
||||
"products:main_product_versions",
|
||||
kwargs={"main_slug": self.slug},
|
||||
)
|
||||
|
||||
|
||||
class SubProduct(models.Model):
|
||||
DISTRIBUTION_INSTALLABLE = DISTRIBUTION_INSTALLABLE
|
||||
DISTRIBUTION_PACKAGE = DISTRIBUTION_PACKAGE
|
||||
DISTRIBUTION_CHOICES = DISTRIBUTION_CHOICES
|
||||
|
||||
main_product = models.ForeignKey(
|
||||
MainProduct,
|
||||
on_delete=models.CASCADE,
|
||||
@@ -156,41 +112,16 @@ class SubProduct(models.Model):
|
||||
logo = models.ImageField(upload_to="products/sub_logos/", blank=True, null=True, help_text="Small logo shown as a corner badge on homepage product cards.")
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
show_on_homepage = models.BooleanField(default=False, help_text="Display this sub-product in the homepage Products section.")
|
||||
homepage_order = models.PositiveIntegerField(default=0, help_text="Order within the homepage Products section.")
|
||||
distribution = models.CharField(
|
||||
max_length=20,
|
||||
choices=DISTRIBUTION_CHOICES,
|
||||
default=DISTRIBUTION_INSTALLABLE,
|
||||
help_text="Only affects how releases appear on the public site.",
|
||||
)
|
||||
package_resource_button_text = models.CharField(
|
||||
max_length=100,
|
||||
default="PyPI",
|
||||
help_text="Label for the package resource button on package releases.",
|
||||
)
|
||||
package_source_button_text = models.CharField(
|
||||
max_length=100,
|
||||
default="Source",
|
||||
help_text="Label for the source code button on package releases.",
|
||||
)
|
||||
downloads_section_link_url = models.URLField(
|
||||
blank=True,
|
||||
help_text="Optional link shown in the top-right corner of the downloads section.",
|
||||
)
|
||||
downloads_section_link_text = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
help_text="Label for the optional downloads section link.",
|
||||
)
|
||||
show_on_homepage = models.BooleanField(default=False, help_text="Display this sub-project in the homepage Projects section.")
|
||||
homepage_order = models.PositiveIntegerField(default=0, help_text="Order within the homepage Projects section.")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order", "name"]
|
||||
unique_together = [["main_product", "slug"]]
|
||||
verbose_name = "Sub Product"
|
||||
verbose_name_plural = "Sub Products"
|
||||
verbose_name = "Sub-project"
|
||||
verbose_name_plural = "Sub-projects"
|
||||
|
||||
@property
|
||||
def rendered_description(self):
|
||||
@@ -213,16 +144,6 @@ class SubProduct(models.Model):
|
||||
},
|
||||
)
|
||||
|
||||
def get_versions_archive_url(self):
|
||||
return reverse(
|
||||
"products:sub_product_versions",
|
||||
kwargs={
|
||||
"main_slug": self.main_product.slug,
|
||||
"sub_slug": self.slug,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Article(models.Model):
|
||||
main_product = models.ForeignKey(
|
||||
MainProduct,
|
||||
@@ -287,7 +208,7 @@ class Article(models.Model):
|
||||
has_sub = self.sub_product_id is not None
|
||||
if has_main == has_sub:
|
||||
raise ValidationError(
|
||||
"An article must belong to exactly one main product or sub-product."
|
||||
"An article must belong to exactly one main project or sub-project."
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
@@ -572,8 +493,8 @@ class ProductVideo(VideoBlockMixin, models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ["order", "pk"]
|
||||
verbose_name = "Product Video"
|
||||
verbose_name_plural = "Product Videos"
|
||||
verbose_name = "Project Video"
|
||||
verbose_name_plural = "Project Videos"
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=(
|
||||
@@ -590,7 +511,7 @@ class ProductVideo(VideoBlockMixin, models.Model):
|
||||
has_sub = self.sub_product_id is not None
|
||||
if has_main == has_sub:
|
||||
raise ValidationError(
|
||||
"A product video must belong to exactly one main product or sub-product."
|
||||
"A project video must belong to exactly one main project or sub-project."
|
||||
)
|
||||
self.clean_video_fields(require=True)
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct, SubProductVersion
|
||||
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct
|
||||
|
||||
|
||||
class ProductViewsSetup(TestCase):
|
||||
@@ -153,198 +149,8 @@ class SubProductDetailViewTest(ProductViewsSetup):
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class SubProductOlderVersionsViewTest(ProductViewsSetup):
|
||||
def test_versions_returns_200(self):
|
||||
from apps.products.models import SubProductVersion
|
||||
|
||||
SubProductVersion.objects.create(
|
||||
sub_product=self.sub_product,
|
||||
version="9.9",
|
||||
is_featured=True,
|
||||
release_channel="stable",
|
||||
windows_download_url="https://example.com/w",
|
||||
is_active=True,
|
||||
)
|
||||
SubProductVersion.objects.create(
|
||||
sub_product=self.sub_product,
|
||||
version="9.8",
|
||||
is_featured=False,
|
||||
windows_download_url="https://example.com/w2",
|
||||
is_active=True,
|
||||
)
|
||||
url = reverse(
|
||||
"products:sub_product_versions",
|
||||
kwargs={"main_slug": "com2care", "sub_slug": "image-processing"},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "products/versions_archive.html")
|
||||
|
||||
def test_main_versions_returns_200(self):
|
||||
from apps.products.models import SubProductVersion
|
||||
|
||||
SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="9.9",
|
||||
is_featured=True,
|
||||
release_channel="stable",
|
||||
windows_download_url="https://example.com/win.exe",
|
||||
is_active=True,
|
||||
)
|
||||
SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="9.8",
|
||||
is_active=True,
|
||||
)
|
||||
url = reverse(
|
||||
"products:main_product_versions",
|
||||
kwargs={"main_slug": "com2care"},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "products/versions_archive.html")
|
||||
|
||||
def test_main_detail_releases_in_context(self):
|
||||
from apps.products.models import SubProductVersion
|
||||
|
||||
SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="1.0",
|
||||
windows_download_url="https://example.com/win.exe",
|
||||
is_active=True,
|
||||
)
|
||||
url = reverse("products:main_product_detail", kwargs={"main_slug": "com2care"})
|
||||
response = self.client.get(url)
|
||||
self.assertTrue(response.context["show_releases_section"])
|
||||
|
||||
def test_installable_shows_release_channel_badges(self):
|
||||
from apps.products.models import RELEASE_CHANNEL_BETA, RELEASE_CHANNEL_PREVIOUS, SubProductVersion
|
||||
|
||||
SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="2.0",
|
||||
release_channel=RELEASE_CHANNEL_BETA,
|
||||
is_featured=True,
|
||||
windows_download_url="https://example.com/beta.exe",
|
||||
is_active=True,
|
||||
)
|
||||
SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="1.9",
|
||||
release_channel=RELEASE_CHANNEL_PREVIOUS,
|
||||
show_on_product_page=True,
|
||||
windows_download_url="https://example.com/prev.exe",
|
||||
is_active=True,
|
||||
)
|
||||
url = reverse("products:main_product_detail", kwargs={"main_slug": "com2care"})
|
||||
response = self.client.get(url)
|
||||
self.assertContains(response, "Beta")
|
||||
self.assertContains(response, "Previous")
|
||||
self.assertEqual(len(response.context["inline_download_channels"]), 1)
|
||||
|
||||
def test_custom_channel_label_overrides_preset(self):
|
||||
from apps.products.models import RELEASE_CHANNEL_STABLE, SubProductVersion
|
||||
|
||||
SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="3.0",
|
||||
release_channel=RELEASE_CHANNEL_STABLE,
|
||||
channel_label="Early Access",
|
||||
is_featured=True,
|
||||
windows_download_url="https://example.com/ea.exe",
|
||||
is_active=True,
|
||||
)
|
||||
url = reverse("products:main_product_detail", kwargs={"main_slug": "com2care"})
|
||||
response = self.client.get(url)
|
||||
self.assertContains(response, "Early Access")
|
||||
self.assertNotContains(response, ">Stable<")
|
||||
|
||||
|
||||
@override_settings(MEDIA_ROOT=settings.BASE_DIR / "test_media_releases")
|
||||
class ReleaseAssetDownloadViewTest(ProductViewsSetup):
|
||||
def test_uploaded_file_download(self):
|
||||
release_file = SimpleUploadedFile("setup.exe", b"binary-payload")
|
||||
version = SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="2.0",
|
||||
windows_download_file=release_file,
|
||||
is_active=True,
|
||||
)
|
||||
url = reverse(
|
||||
"products:release_asset_download",
|
||||
kwargs={"version_id": version.pk, "asset": "windows"},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(b"".join(response.streaming_content), b"binary-payload")
|
||||
self.assertIn("attachment", response["Content-Disposition"])
|
||||
self.assertIn("setup.exe", response["Content-Disposition"])
|
||||
|
||||
def test_external_url_blocks_file_download(self):
|
||||
version = SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="2.1",
|
||||
windows_download_url="https://example.com/win.exe",
|
||||
windows_download_file=SimpleUploadedFile("local.exe", b"x"),
|
||||
is_active=True,
|
||||
)
|
||||
url = reverse(
|
||||
"products:release_asset_download",
|
||||
kwargs={"version_id": version.pk, "asset": "windows"},
|
||||
)
|
||||
self.assertEqual(self.client.get(url).status_code, 404)
|
||||
|
||||
def test_product_page_uses_file_when_no_url(self):
|
||||
version = SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="3.0",
|
||||
windows_download_file=SimpleUploadedFile("win.tar.gz", b"gz"),
|
||||
is_active=True,
|
||||
)
|
||||
detail_url = reverse("products:main_product_detail", kwargs={"main_slug": "com2care"})
|
||||
response = self.client.get(detail_url)
|
||||
download_url = reverse(
|
||||
"products:release_asset_download",
|
||||
kwargs={"version_id": version.pk, "asset": "windows"},
|
||||
)
|
||||
self.assertContains(response, download_url)
|
||||
|
||||
def test_inactive_version_returns_404(self):
|
||||
version = SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="0.1",
|
||||
windows_download_file=SimpleUploadedFile("a.exe", b"a"),
|
||||
is_active=False,
|
||||
)
|
||||
url = reverse(
|
||||
"products:release_asset_download",
|
||||
kwargs={"version_id": version.pk, "asset": "windows"},
|
||||
)
|
||||
self.assertEqual(self.client.get(url).status_code, 404)
|
||||
|
||||
def test_clearing_file_removes_from_disk(self):
|
||||
version = SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="4.0",
|
||||
windows_download_file=SimpleUploadedFile("remove-me.exe", b"data"),
|
||||
is_active=True,
|
||||
)
|
||||
stored_path = version.windows_download_file.path
|
||||
self.assertTrue(stored_path)
|
||||
version.windows_download_file = ""
|
||||
version.save()
|
||||
version.refresh_from_db()
|
||||
self.assertFalse(version.windows_download_file)
|
||||
self.assertEqual(version.windows_download_filename, "")
|
||||
self.assertFalse(os.path.exists(stored_path))
|
||||
|
||||
def test_deleting_version_removes_files_from_disk(self):
|
||||
version = SubProductVersion.objects.create(
|
||||
main_product=self.main_product,
|
||||
version="5.0",
|
||||
windows_download_file=SimpleUploadedFile("gone.exe", b"data"),
|
||||
is_active=True,
|
||||
)
|
||||
stored_path = version.windows_download_file.path
|
||||
version.delete()
|
||||
self.assertFalse(os.path.exists(stored_path))
|
||||
class RemovedDistributionViewsTest(ProductViewsSetup):
|
||||
def test_project_pages_do_not_expose_downloads_or_versions(self):
|
||||
response = self.client.get(self.main_product.get_absolute_url())
|
||||
self.assertNotContains(response, "Downloads")
|
||||
self.assertNotContains(response, "Older releases")
|
||||
|
||||
@@ -6,11 +6,6 @@ app_name = "products"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.ProductOverviewView.as_view(), name="overview"),
|
||||
path(
|
||||
"releases/<int:version_id>/<slug:asset>/download/",
|
||||
views.ReleaseAssetDownloadView.as_view(),
|
||||
name="release_asset_download",
|
||||
),
|
||||
path(
|
||||
"<slug:main_slug>/videos/",
|
||||
views.ProductVideoArchiveView.as_view(),
|
||||
@@ -26,16 +21,6 @@ urlpatterns = [
|
||||
views.MainProductDetailView.as_view(),
|
||||
name="main_product_detail",
|
||||
),
|
||||
path(
|
||||
"<slug:main_slug>/versions/",
|
||||
views.MainProductOlderVersionsView.as_view(),
|
||||
name="main_product_versions",
|
||||
),
|
||||
path(
|
||||
"<slug:main_slug>/<slug:sub_slug>/versions/",
|
||||
views.SubProductOlderVersionsView.as_view(),
|
||||
name="sub_product_versions",
|
||||
),
|
||||
path(
|
||||
"<slug:main_slug>/<slug:sub_slug>/",
|
||||
views.SubProductDetailView.as_view(),
|
||||
|
||||
+2
-95
@@ -1,14 +1,9 @@
|
||||
import mimetypes
|
||||
|
||||
from django.http import FileResponse, Http404
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.views import View
|
||||
from django.views.generic import DetailView, ListView, TemplateView
|
||||
|
||||
from .models import Article, ArticleCitation, ArticleSection, MainProduct, ProductVideo, SubProduct, SubProductVersion
|
||||
from .release_assets import FILE_FIELD_BY_ASSET_KEY, RELEASE_ASSET_KEYS, URL_FIELD_BY_ASSET_KEY
|
||||
from .release_context import build_archive_context, build_release_context
|
||||
from .models import MainProduct, ProductVideo, SubProduct
|
||||
|
||||
VIDEO_PREVIEW_LIMIT = 3
|
||||
VIDEO_ARCHIVE_PAGE_SIZE = 6
|
||||
@@ -23,31 +18,6 @@ def _product_video_preview(queryset, archive_url):
|
||||
}
|
||||
|
||||
|
||||
class ReleaseAssetDownloadView(View):
|
||||
def get(self, request, version_id, asset):
|
||||
if asset not in RELEASE_ASSET_KEYS:
|
||||
raise Http404
|
||||
version = get_object_or_404(SubProductVersion, pk=version_id, is_active=True)
|
||||
parent = version.sub_product or version.main_product
|
||||
if parent is None or not parent.is_active:
|
||||
raise Http404
|
||||
url_field = URL_FIELD_BY_ASSET_KEY[asset]
|
||||
if (getattr(version, url_field) or "").strip():
|
||||
raise Http404
|
||||
file_field = FILE_FIELD_BY_ASSET_KEY[asset]
|
||||
release_file = getattr(version, file_field)
|
||||
if not release_file:
|
||||
raise Http404
|
||||
filename = version.release_download_filename(asset)
|
||||
content_type, _ = mimetypes.guess_type(filename)
|
||||
return FileResponse(
|
||||
release_file.open("rb"),
|
||||
as_attachment=True,
|
||||
filename=filename,
|
||||
content_type=content_type or "application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
class ProductOverviewView(ListView):
|
||||
model = MainProduct
|
||||
template_name = "products/overview.html"
|
||||
@@ -81,35 +51,6 @@ class MainProductDetailView(DetailView):
|
||||
),
|
||||
)
|
||||
)
|
||||
release_context = build_release_context(
|
||||
self.object.distribution,
|
||||
self.object.versions.filter(is_active=True),
|
||||
self.object,
|
||||
)
|
||||
release_context["versions_archive_url"] = self.object.get_versions_archive_url()
|
||||
context.update(release_context)
|
||||
return context
|
||||
|
||||
|
||||
class MainProductOlderVersionsView(TemplateView):
|
||||
template_name = "products/versions_archive.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
main_product = get_object_or_404(
|
||||
MainProduct,
|
||||
slug=self.kwargs["main_slug"],
|
||||
is_active=True,
|
||||
)
|
||||
active_versions = main_product.versions.filter(is_active=True)
|
||||
context["main_product"] = main_product
|
||||
context["sub_product"] = None
|
||||
context["product_detail_url"] = main_product.get_absolute_url()
|
||||
context.update(
|
||||
build_archive_context(
|
||||
main_product.distribution, active_versions, main_product
|
||||
)
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
@@ -152,12 +93,6 @@ class SubProductDetailView(TemplateView):
|
||||
.exclude(pk=sub_product.pk)
|
||||
.order_by("order", "name")
|
||||
)
|
||||
active_versions = sub_product.versions.filter(is_active=True)
|
||||
release_context = build_release_context(
|
||||
sub_product.distribution, active_versions, sub_product
|
||||
)
|
||||
release_context["versions_archive_url"] = sub_product.get_versions_archive_url()
|
||||
context.update(release_context)
|
||||
return context
|
||||
|
||||
|
||||
@@ -203,31 +138,3 @@ class ProductVideoArchiveView(ListView):
|
||||
}
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class SubProductOlderVersionsView(TemplateView):
|
||||
template_name = "products/versions_archive.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
main_product = get_object_or_404(
|
||||
MainProduct,
|
||||
slug=self.kwargs["main_slug"],
|
||||
is_active=True,
|
||||
)
|
||||
sub_product = get_object_or_404(
|
||||
SubProduct,
|
||||
slug=self.kwargs["sub_slug"],
|
||||
main_product=main_product,
|
||||
is_active=True,
|
||||
)
|
||||
active_versions = sub_product.versions.filter(is_active=True)
|
||||
context["main_product"] = main_product
|
||||
context["sub_product"] = sub_product
|
||||
context["product_detail_url"] = sub_product.get_absolute_url()
|
||||
context.update(
|
||||
build_archive_context(
|
||||
sub_product.distribution, active_versions, sub_product
|
||||
)
|
||||
)
|
||||
return context
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ admin.site.index_title = "Welcome to Communication to Care Administration"
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("products/", include("apps.products.urls", namespace="products")),
|
||||
path("projects/", include("apps.products.urls", namespace="products")),
|
||||
path("", include("apps.pages.urls", namespace="pages")),
|
||||
]
|
||||
|
||||
|
||||
+3
-1
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,9 +5,6 @@ const SELECTORS = {
|
||||
navbarToggle: '#navbarToggle',
|
||||
navbarMenu: '#navbarMenu',
|
||||
productsNavItem: '#productsNavItem',
|
||||
faqItems: '.faq-item',
|
||||
faqQuestion: '.faq-question',
|
||||
faqAnswer: '.faq-answer',
|
||||
fadeInElements: '.fade-in',
|
||||
citationCopy: '.citation-copy',
|
||||
};
|
||||
@@ -192,33 +189,6 @@ function initMegaMenu() {
|
||||
});
|
||||
}
|
||||
|
||||
function initFAQAccordion() {
|
||||
const faqItems = document.querySelectorAll(SELECTORS.faqItems);
|
||||
faqItems.forEach((item) => {
|
||||
const question = item.querySelector(SELECTORS.faqQuestion);
|
||||
const answer = item.querySelector(SELECTORS.faqAnswer);
|
||||
if (!question || !answer) return;
|
||||
|
||||
question.addEventListener('click', () => {
|
||||
const isOpen = item.classList.contains('open');
|
||||
|
||||
faqItems.forEach((other) => {
|
||||
if (other !== item) {
|
||||
other.classList.remove('open');
|
||||
const otherQuestion = other.querySelector(SELECTORS.faqQuestion);
|
||||
const otherAnswer = other.querySelector(SELECTORS.faqAnswer);
|
||||
if (otherQuestion) otherQuestion.setAttribute('aria-expanded', 'false');
|
||||
if (otherAnswer) otherAnswer.hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
item.classList.toggle('open', !isOpen);
|
||||
question.setAttribute('aria-expanded', String(!isOpen));
|
||||
answer.hidden = isOpen;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initIntersectionObserver() {
|
||||
const elements = document.querySelectorAll(SELECTORS.fadeInElements);
|
||||
if (!elements.length) return;
|
||||
@@ -329,7 +299,6 @@ function init() {
|
||||
initNavbarScroll();
|
||||
initMobileMenu();
|
||||
initMegaMenu();
|
||||
initFAQAccordion();
|
||||
initIntersectionObserver();
|
||||
initReadMore();
|
||||
initCitationCopy();
|
||||
|
||||
+4
-3
@@ -4,12 +4,12 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="{% block meta_description %}Communication to Care — Advanced Medical Imaging & Radiomics Solutions{% endblock %}" />
|
||||
<meta property="og:site_name" content="Communication to Care" />
|
||||
<meta name="description" content="{% block meta_description %}{{ site_branding.meta_description }}{% endblock %}" />
|
||||
<meta property="og:site_name" content="{{ site_branding.site_name }}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="https://com2care.com{% static 'images/com2care-care-team.jpg' %}" />
|
||||
<link rel="canonical" href="https://com2care.com{{ request.path }}" />
|
||||
<title>{% block title %}Communication to Care{% endblock %} | Communication to Care</title>
|
||||
<title>{% block title %}{{ site_branding.site_name }}{% endblock %} | {{ site_branding.site_name }}</title>
|
||||
{% if site_branding.website_icon %}
|
||||
<link rel="icon" href="{{ site_branding.website_icon.url }}" />
|
||||
<link rel="apple-touch-icon" href="{{ site_branding.website_icon.url }}" />
|
||||
@@ -21,6 +21,7 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="{% static 'css/main.css' %}" />
|
||||
<style>:root { {{ site_branding.theme_style }} }</style>
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,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 %}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ post.title }}{% endblock %}
|
||||
{% block meta_description %}{{ post.excerpt|default:post.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="blog-detail">
|
||||
<header class="page-hero blog-detail-hero">
|
||||
<div class="page-hero-blobs" aria-hidden="true"><div class="blob blob--1"></div><div class="blob blob--2"></div></div>
|
||||
<div class="container blog-detail-header">
|
||||
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||
<ol class="breadcrumb-list" role="list">
|
||||
<li><a href="{% url 'pages:home' %}">Home</a></li>
|
||||
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
||||
<li><a href="{% url 'pages:blog_list' %}">Blog</a></li>
|
||||
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
||||
<li aria-current="page">{{ post.title }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<p class="blog-meta">{{ post.published_at|date:"F j, Y" }}{% if post.author_name %} · {{ post.author_name }}{% endif %}</p>
|
||||
<h1 class="page-hero-title">{{ post.title }}</h1>
|
||||
{% if post.excerpt %}<p class="page-hero-subtitle">{{ post.excerpt }}</p>{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
<div class="section">
|
||||
<div class="container blog-article-wrap">
|
||||
{% if post.cover_image %}<img class="blog-cover" src="{{ post.cover_image.url }}" alt="{{ post.cover_image_alt|default:post.title }}" />{% endif %}
|
||||
<div class="blog-article rich-content glass-card">{{ post.rendered_content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Blog{% endblock %}
|
||||
{% block meta_description %}News, research notes, and community updates from {{ site_branding.site_name }}.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-hero" aria-labelledby="blog-heading">
|
||||
<div class="page-hero-blobs" aria-hidden="true">
|
||||
<div class="blob blob--1"></div>
|
||||
<div class="blob blob--2"></div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="page-hero-content">
|
||||
<div class="section-badge">Insights and Updates</div>
|
||||
<h1 class="page-hero-title" id="blog-heading">Blog</h1>
|
||||
<p class="page-hero-subtitle">Research stories, project updates, and practical ideas from our community.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" aria-label="Blog posts">
|
||||
<div class="container">
|
||||
{% if posts %}
|
||||
<div class="blog-grid">
|
||||
{% for post in posts %}
|
||||
<article class="blog-card glass-card fade-in">
|
||||
{% if post.cover_image %}
|
||||
<a href="{{ post.get_absolute_url }}" class="blog-card-image">
|
||||
<img src="{{ post.cover_image.url }}" alt="{{ post.cover_image_alt|default:post.title }}" loading="lazy" />
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="blog-card-body">
|
||||
<p class="blog-meta">{{ post.published_at|date:"F j, Y" }}{% if post.author_name %} · {{ post.author_name }}{% endif %}</p>
|
||||
<h2 class="blog-card-title"><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h2>
|
||||
{% if post.excerpt %}<p class="blog-card-excerpt">{{ post.excerpt }}</p>{% endif %}
|
||||
<a href="{{ post.get_absolute_url }}" class="link-arrow">Read article</a>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% include "partials/_pagination.html" with page_obj=page_obj %}
|
||||
{% else %}
|
||||
<div class="empty-state-block glass-card"><p>No blog posts have been published yet.</p></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,8 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Contact{% endblock %}
|
||||
{% block meta_description %}Contact Communication to Care for com2care product support, research evaluation, and general inquiries.{% endblock %}
|
||||
{% block title %}Become a Member{% endblock %}
|
||||
{% block meta_description %}Become a member of {{ site_branding.site_name }} and connect with the com2care research community.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="page-hero-content">
|
||||
<div class="section-badge">Get in Touch</div>
|
||||
<h1 class="page-hero-title" id="contact-heading">Contact Us</h1>
|
||||
<p class="page-hero-subtitle">Have a product, evaluation, or research question? Send the com2care team a message below.</p>
|
||||
<div class="section-badge">Join the Community</div>
|
||||
<h1 class="page-hero-title" id="contact-heading">Become a Member</h1>
|
||||
<p class="page-hero-subtitle">Tell us how you would like to contribute, collaborate, or take part in the com2care research community.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -27,7 +27,7 @@
|
||||
<div class="contact-layout{% if not site_contact.has_contact_sidebar %} contact-layout--full{% endif %}">
|
||||
|
||||
<div class="contact-form-wrap glass-card fade-in">
|
||||
<h2 class="contact-form-title" id="contact-form-heading">Send a Message</h2>
|
||||
<h2 class="contact-form-title" id="contact-form-heading">Membership Request</h2>
|
||||
<form id="contactForm" novalidate action="{% url 'pages:contact' %}" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}FAQ{% endblock %}
|
||||
{% block meta_description %}Frequently asked questions about Communication to Care licensing, research use, releases, support, and responsible deployment.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<section class="page-hero" aria-labelledby="faq-heading">
|
||||
<div class="page-hero-blobs" aria-hidden="true">
|
||||
<div class="blob blob--1"></div>
|
||||
<div class="blob blob--2"></div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="page-hero-content">
|
||||
<div class="section-badge">Help</div>
|
||||
<h1 class="page-hero-title" id="faq-heading">FAQ</h1>
|
||||
<p class="page-hero-subtitle">Frequently Asked Questions</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% include "partials/_page_videos.html" with videos=faq_videos %}
|
||||
|
||||
<section class="section" aria-labelledby="faq-list-heading">
|
||||
<div class="container">
|
||||
<h2 class="sr-only" id="faq-list-heading">FAQ List</h2>
|
||||
{% if faq_entries %}
|
||||
<div class="faq-list">
|
||||
{% for entry in faq_entries %}
|
||||
<div class="faq-item glass-card fade-in">
|
||||
<button
|
||||
class="faq-question"
|
||||
aria-expanded="false"
|
||||
aria-controls="faq-answer-{{ entry.pk }}"
|
||||
id="faq-question-{{ entry.pk }}"
|
||||
>
|
||||
{{ entry.question }}
|
||||
<svg class="faq-icon" width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<path d="M5 8L10 13L15 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
class="faq-answer"
|
||||
id="faq-answer-{{ entry.pk }}"
|
||||
role="region"
|
||||
aria-labelledby="faq-question-{{ entry.pk }}"
|
||||
hidden
|
||||
>
|
||||
<div class="rich-content">{{ entry.rendered_answer }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">No FAQ entries available yet.</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="faq-contact glass-card fade-in">
|
||||
<h2>Still Have Questions?</h2>
|
||||
<p>Can't find the answer you're looking for? Get in touch with our support team.</p>
|
||||
<a href="{% url 'pages:contact' %}" class="btn-primary">Contact Support</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
@@ -2,7 +2,7 @@
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Home{% endblock %}
|
||||
{% block meta_description %}Communication to Care — A Powerful Workflow Generator for Standardized Radiomics Analysis and Medical Image Visualization.{% endblock %}
|
||||
{% block meta_description %}{{ site_branding.meta_description }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<div class="hero-app-preview fade-in">
|
||||
<div class="hero-app-preview-glow" aria-hidden="true"></div>
|
||||
{% if site_branding.hero_logo %}
|
||||
<img src="{{ site_branding.hero_logo.url }}" alt="{{ site_branding.hero_logo_alt|default:'Communication to Care' }}" loading="eager" />
|
||||
<img src="{{ site_branding.hero_logo.url }}" alt="{{ site_branding.hero_logo_alt|default:site_branding.site_name }}" loading="eager" />
|
||||
{% elif hero and hero.image %}
|
||||
<img src="{{ hero.image.url }}" alt="{{ hero.image_alt }}" loading="eager" />
|
||||
{% else %}
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
<div class="footer-grid">
|
||||
|
||||
<div class="footer-brand-col">
|
||||
<a href="{% url 'pages:home' %}" class="footer-logo" aria-label="Communication to Care home">
|
||||
<a href="{% url 'pages:home' %}" class="footer-logo" aria-label="{{ site_branding.site_name }} home">
|
||||
{% include "partials/_brand_icon.html" with placement="footer" %}
|
||||
<span class="brand-name">Communication to Care</span>
|
||||
<span class="brand-name">{{ site_branding.site_name }}</span>
|
||||
</a>
|
||||
<p class="footer-tagline">
|
||||
Turning complex medical-imaging evidence into clearer, reproducible research conversations.
|
||||
{{ site_branding.tagline }}
|
||||
</p>
|
||||
{% include "partials/_contact_discord.html" with button_class="btn-ghost btn-sm" %}
|
||||
</div>
|
||||
@@ -22,22 +22,22 @@
|
||||
<h3 class="footer-heading">Navigation</h3>
|
||||
<ul class="footer-links" role="list">
|
||||
<li><a href="{% url 'pages:home' %}">Home</a></li>
|
||||
<li><a href="{% url 'pages:about' %}">What is Communication to Care</a></li>
|
||||
<li><a href="{% url 'products:overview' %}">Products</a></li>
|
||||
<li><a href="{% url 'pages:faq' %}">FAQ</a></li>
|
||||
<li><a href="{% url 'pages:contact' %}">Contact</a></li>
|
||||
<li><a href="{% url 'pages:about' %}">About {{ site_branding.site_name }}</a></li>
|
||||
<li><a href="{% url 'products:overview' %}">Projects</a></li>
|
||||
<li><a href="{% url 'pages:blog_list' %}">Blog</a></li>
|
||||
<li><a href="{% url 'pages:contact' %}">Become a Member</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% if footer_main_products %}
|
||||
<div class="footer-links-col">
|
||||
<h3 class="footer-heading">Products</h3>
|
||||
<h3 class="footer-heading">Projects</h3>
|
||||
<ul class="footer-links" role="list">
|
||||
{% for product in footer_main_products %}
|
||||
<li><a href="{{ product.get_absolute_url }}">{{ product.name }}</a></li>
|
||||
{% endfor %}
|
||||
{% if footer_main_products_has_more %}
|
||||
<li><a href="{% url 'products:overview' %}">More products</a></li>
|
||||
<li><a href="{% url 'products:overview' %}">More projects</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
{% if site_contact.has_footer_contact %}
|
||||
<div class="footer-contact-col">
|
||||
<h3 class="footer-heading">Contact</h3>
|
||||
<h3 class="footer-heading">Membership</h3>
|
||||
{% include "partials/_contact_office.html" with address_class="footer-address" %}
|
||||
{% include "partials/_contact_email.html" with link_class="footer-email" %}
|
||||
</div>
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
<div class="footer-bottom">
|
||||
<p class="footer-copy">
|
||||
© {% now "Y" %} Communication to Care. All rights reserved.
|
||||
© {% now "Y" %} {{ site_branding.site_name }}. All rights reserved.
|
||||
</p>
|
||||
<p class="footer-credit">
|
||||
Research collaboration at <a href="https://com2care.com">com2care.com</a>
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<nav class="navbar" id="navbar" role="navigation" aria-label="Main navigation">
|
||||
<div class="navbar-container">
|
||||
|
||||
<a href="{% url 'pages:home' %}" class="navbar-brand" aria-label="Communication to Care home">
|
||||
<a href="{% url 'pages:home' %}" class="navbar-brand" aria-label="{{ site_branding.site_name }} home">
|
||||
{% include "partials/_brand_icon.html" with placement="navbar" %}
|
||||
<span class="brand-name">com2care</span>
|
||||
<span class="brand-name">{{ site_branding.short_name }}</span>
|
||||
</a>
|
||||
|
||||
<button class="navbar-toggle" id="navbarToggle" aria-expanded="false" aria-controls="navbarMenu" aria-label="Toggle navigation">
|
||||
@@ -25,14 +25,14 @@
|
||||
{% if nav_main_products %}
|
||||
<li class="nav-item has-megamenu" id="productsNavItem">
|
||||
<a href="{% url 'products:overview' %}" class="nav-link {% if 'products' in request.resolver_match.namespace %}active{% endif %}" aria-haspopup="true" aria-expanded="false" id="productsTrigger">
|
||||
Products
|
||||
Projects
|
||||
<svg class="nav-arrow" width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="M2 4L6 8L10 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</a>
|
||||
<div class="megamenu" role="menu" aria-labelledby="productsTrigger">
|
||||
<div class="megamenu-inner">
|
||||
<div class="megamenu-products-scroll" tabindex="0" aria-label="Product list">
|
||||
<div class="megamenu-products-scroll" tabindex="0" aria-label="Project list">
|
||||
<ul class="megamenu-products" role="list">
|
||||
{% for product in nav_main_products %}
|
||||
<li class="megamenu-product-item{% if product.sub_products.all %} has-subproducts{% endif %}">
|
||||
@@ -71,6 +71,12 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'pages:blog_list' %}" class="nav-link {% if request.resolver_match.url_name == 'blog_list' or request.resolver_match.url_name == 'blog_detail' %}active{% endif %}">
|
||||
Blog
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% for page in nav_custom_pages %}
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'pages:custom_page' slug=page.slug %}" class="nav-link {% if request.resolver_match.url_name == 'custom_page' and request.resolver_match.kwargs.slug == page.slug %}active{% endif %}">
|
||||
@@ -79,15 +85,9 @@
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'pages:faq' %}" class="nav-link {% if request.resolver_match.url_name == 'faq' %}active{% endif %}">
|
||||
FAQ
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'pages:contact' %}" class="nav-link nav-link--cta {% if request.resolver_match.url_name == 'contact' %}active{% endif %}">
|
||||
Contact
|
||||
Become a Member
|
||||
</a>
|
||||
</li>
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@
|
||||
<div class="section-header">
|
||||
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
|
||||
{% if section.title %}<h2 class="section-title" id="features-heading-{{ section.pk }}">{{ section.title }}</h2>{% endif %}
|
||||
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
|
||||
{% if section.description %}<div class="rich-content section-desc">{{ section.rendered_description }}</div>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="features-grid">
|
||||
{% for item in section.items.all %}
|
||||
@@ -183,7 +183,7 @@
|
||||
<div class="section-header">
|
||||
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
|
||||
{% if section.title %}<h2 class="section-title" id="screenshots-heading-{{ section.pk }}">{{ section.title }}</h2>{% endif %}
|
||||
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
|
||||
{% if section.description %}<div class="rich-content section-desc">{{ section.rendered_description }}</div>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="screenshots-grid">
|
||||
{% for item in section.items.all %}
|
||||
@@ -217,7 +217,7 @@
|
||||
<div class="section-header">
|
||||
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
|
||||
{% if section.title %}<h2 class="section-title" id="products-heading-{{ section.pk }}">{{ section.title }}</h2>{% endif %}
|
||||
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
|
||||
{% if section.description %}<div class="rich-content section-desc">{{ section.rendered_description }}</div>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="products-grid">
|
||||
{% for product in homepage_products %}
|
||||
@@ -251,7 +251,7 @@
|
||||
<div class="section-header">
|
||||
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
|
||||
{% if section.title %}<h2 class="section-title" id="products-catalog-heading-{{ section.pk }}">{{ section.title }}</h2>{% endif %}
|
||||
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
|
||||
{% if section.description %}<div class="rich-content section-desc">{{ section.rendered_description }}</div>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="products-overview-grid">
|
||||
{% for product in homepage_products_catalog %}
|
||||
@@ -264,7 +264,7 @@
|
||||
<div class="product-overview-body">
|
||||
<h3 class="product-overview-title">{{ product.name }}</h3>
|
||||
<p class="product-overview-short">{{ product.short_description }}</p>
|
||||
<p class="product-overview-desc" data-readmore="110">{{ product.description }}</p>
|
||||
<div class="product-overview-desc rich-content" data-readmore="110">{{ product.rendered_description }}</div>
|
||||
<a href="{{ product.get_absolute_url }}" class="btn-primary">Explore {{ product.name }}</a>
|
||||
</div>
|
||||
{% if product.sub_products.all %}
|
||||
@@ -297,7 +297,7 @@
|
||||
<div class="section-header">
|
||||
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
|
||||
{% if section.title %}<h2 class="section-title" id="problems-heading-{{ section.pk }}">{{ section.title }}</h2>{% endif %}
|
||||
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
|
||||
{% if section.description %}<div class="rich-content section-desc">{{ section.rendered_description }}</div>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="problems-grid">
|
||||
{% for item in section.items.all %}
|
||||
@@ -317,7 +317,7 @@
|
||||
<div class="section-header">
|
||||
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
|
||||
{% if section.title %}<h2 class="section-title" id="supporters-heading-{{ section.pk }}">{{ section.title }}</h2>{% endif %}
|
||||
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
|
||||
{% if section.description %}<div class="rich-content section-desc">{{ section.rendered_description }}</div>{% elif section.content %}<div class="rich-content section-desc">{{ section.rendered_content }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="supporters-grid">
|
||||
{% for item in section.items.all %}
|
||||
@@ -344,7 +344,7 @@
|
||||
<div class="about-strip-content">
|
||||
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
|
||||
{% if section.title %}<h2 class="section-title" id="about-strip-heading-{{ section.pk }}">{{ section.title }}</h2>{% endif %}
|
||||
{% if section.description %}<p class="about-strip-text">{{ section.description }}</p>{% elif section.content %}<p class="about-strip-text">{{ section.rendered_content }}</p>{% endif %}
|
||||
{% if section.description %}<div class="about-strip-text rich-content">{{ section.rendered_description }}</div>{% elif section.content %}<div class="about-strip-text rich-content">{{ section.rendered_content }}</div>{% endif %}
|
||||
{% if section.link_text and section.link_url %}
|
||||
<a href="{{ section.link_url }}" class="btn-primary">{{ section.link_text }}</a>
|
||||
{% endif %}
|
||||
@@ -357,60 +357,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% elif section.section_type == "faq" %}
|
||||
<section class="section" aria-labelledby="faq-section-{{ section.pk }}-heading">
|
||||
<div class="container">
|
||||
{% if section.badge or section.title %}
|
||||
<div class="section-header">
|
||||
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
|
||||
{% if section.title %}<h2 class="section-title" id="faq-section-{{ section.pk }}-heading">{{ section.title }}</h2>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% with items=section.items.all %}
|
||||
{% if items %}
|
||||
<div class="faq-list">
|
||||
{% for item in items %}
|
||||
{% if item.url %}
|
||||
{% include "partials/_linked_card_open.html" with url=item.url card_class="faq-item faq-item--link" %}
|
||||
<div class="faq-question">
|
||||
{{ item.title }}
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M3 8H13M13 8L9 4M13 8L9 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% if item.content %}<div class="rich-content faq-link-preview">{{ item.rendered_content }}</div>{% endif %}
|
||||
{% include "partials/_linked_card_close.html" with url=item.url %}
|
||||
{% else %}
|
||||
<div class="faq-item glass-card fade-in">
|
||||
<button
|
||||
class="faq-question"
|
||||
aria-expanded="false"
|
||||
aria-controls="faq-answer-{{ section.pk }}-{{ item.pk }}"
|
||||
id="faq-question-{{ section.pk }}-{{ item.pk }}"
|
||||
>
|
||||
{{ item.title }}
|
||||
<svg class="faq-icon" width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<path d="M5 8L10 13L15 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
class="faq-answer"
|
||||
id="faq-answer-{{ section.pk }}-{{ item.pk }}"
|
||||
role="region"
|
||||
aria-labelledby="faq-question-{{ section.pk }}-{{ item.pk }}"
|
||||
hidden
|
||||
>
|
||||
<div class="rich-content">{{ item.rendered_content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% elif section.section_type == "video" %}
|
||||
{% include "partials/_video_section.html" with section=section %}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{% if page_obj and page_obj.paginator.num_pages > 1 %}
|
||||
<nav class="pagination" aria-label="Pagination">
|
||||
{% if page_obj.has_previous %}<a class="btn-ghost btn-sm" href="?page={{ page_obj.previous_page_number }}">Previous</a>{% endif %}
|
||||
<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
|
||||
{% if page_obj.has_next %}<a class="btn-ghost btn-sm" href="?page={{ page_obj.next_page_number }}">Next</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
@@ -1,34 +0,0 @@
|
||||
{% load static %}
|
||||
<div class="sub-downloads-channel{% if channel.is_previous %} sub-downloads-channel--prev{% endif %}">
|
||||
{% include "products/_release_channel_badge.html" with version=channel.version %}
|
||||
{% if channel.install_cells %}
|
||||
<div class="sub-downloads-grid">
|
||||
{% for cell in channel.install_cells %}
|
||||
<div class="sub-dl-item">
|
||||
<div class="sub-dl-icon-wrap">
|
||||
{% if cell.icon %}
|
||||
<img src="{% static cell.icon %}" alt="" width="32" height="32" class="platform-icon sub-dl-icon" aria-hidden="true" />
|
||||
{% else %}
|
||||
<span class="sub-dl-source-mark" aria-hidden="true">
|
||||
<svg class="sub-dl-source-svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round">
|
||||
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
|
||||
</svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="sub-dl-platform">{{ cell.label }}</span>
|
||||
<span class="download-version">{{ channel.version.version }}</span>
|
||||
<a href="{{ cell.url }}" class="btn-primary btn--sm"{% if cell.external %} target="_blank" rel="noopener noreferrer"{% endif %}>{% if cell.label == "Source code" %}Source{% else %}Download{% endif %}</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if channel.version.package_resource_url %}
|
||||
<p class="sub-install-extra-resource">
|
||||
<a href="{{ channel.version.package_resource_url }}" target="_blank" rel="noopener noreferrer">Package resource</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if channel.version.release_notes %}
|
||||
<p class="sub-release-notes">{{ channel.version.release_notes }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -1,10 +0,0 @@
|
||||
{% if version.display_channel_label %}
|
||||
<span class="release-channel-label release-channel-label--{{ version.display_channel_css_modifier }}">
|
||||
{% if version.release_channel == "stable" %}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
{{ version.display_channel_label }}
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -1,69 +0,0 @@
|
||||
{% load static %}
|
||||
{% if show_releases_section %}
|
||||
{% if distribution_installable %}
|
||||
<section class="sub-downloads fade-in" aria-labelledby="sub-dl-heading">
|
||||
<div class="sub-downloads-header">
|
||||
<h2 class="sub-downloads-title" id="sub-dl-heading">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Downloads
|
||||
</h2>
|
||||
{% if downloads_section_link %}
|
||||
<a href="{{ downloads_section_link.url }}" class="sub-downloads-corner-link link-arrow" target="_blank" rel="noopener noreferrer">{{ downloads_section_link.text }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if featured_version %}
|
||||
{% include "products/_installable_download_channel.html" with channel=featured_channel %}
|
||||
{% endif %}
|
||||
|
||||
{% for channel in inline_download_channels %}
|
||||
{% include "products/_installable_download_channel.html" %}
|
||||
{% endfor %}
|
||||
|
||||
{% if show_older_versions_link %}
|
||||
<p class="sub-older-versions-inner">
|
||||
<a href="{{ versions_archive_url }}" class="link-arrow">Older releases</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% elif distribution_package %}
|
||||
<section class="sub-downloads sub-downloads--package fade-in" aria-labelledby="sub-resources-heading">
|
||||
<div class="sub-downloads-header">
|
||||
<h2 class="sub-downloads-title" id="sub-resources-heading">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
Resources
|
||||
</h2>
|
||||
{% if downloads_section_link %}
|
||||
<a href="{{ downloads_section_link.url }}" class="sub-downloads-corner-link link-arrow" target="_blank" rel="noopener noreferrer">{{ downloads_section_link.text }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<ul class="pkg-version-list" role="list">
|
||||
{% for ver in package_versions %}
|
||||
<li class="pkg-version-row">
|
||||
<div class="pkg-version-meta">
|
||||
<span class="download-version">{{ ver.version }}</span>
|
||||
{% include "products/_release_channel_badge.html" with version=ver %}
|
||||
</div>
|
||||
<div class="pkg-version-actions">
|
||||
{% if ver.package_resource_url %}
|
||||
<a href="{{ ver.package_resource_url }}" class="btn-primary btn--sm" target="_blank" rel="noopener noreferrer">{{ package_resource_button_text }}</a>
|
||||
{% endif %}
|
||||
{% if ver.resolved_source_code_url %}
|
||||
<a href="{{ ver.resolved_source_code_url }}" class="btn-ghost btn--sm"{% if ver.source_code_url %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ package_source_button_text }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if ver.release_notes %}
|
||||
<p class="pkg-version-notes">{{ ver.release_notes }}</p>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -1,70 +0,0 @@
|
||||
{% load static %}
|
||||
{% if not archive_versions %}
|
||||
<p class="versions-empty glass-card">There are no archived releases{% if sub_product %} for this module{% else %} for this product{% endif %}.</p>
|
||||
{% elif distribution_installable %}
|
||||
<div class="versions-table-wrap glass-card fade-in">
|
||||
<table class="versions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Version</th>
|
||||
{% for fname, label, icon in archive_specs %}
|
||||
<th scope="col">{{ label }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in archive_rows_installable %}
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<span class="versions-table-version">{{ row.version_obj.version }}</span>
|
||||
{% include "products/_release_channel_badge.html" with version=row.version_obj %}
|
||||
</th>
|
||||
{% for cell in row.cells %}
|
||||
<td>
|
||||
{% if cell.url %}
|
||||
{% if cell.icon %}
|
||||
<a href="{{ cell.url }}" class="versions-table-link"{% if cell.external %} target="_blank" rel="noopener noreferrer"{% endif %}>
|
||||
<img src="{% static cell.icon %}" alt="" width="22" height="22" class="versions-table-icon" aria-hidden="true" />
|
||||
<span class="sr-only">{{ cell.label }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ cell.url }}" class="versions-table-text-link"{% if cell.external %} target="_blank" rel="noopener noreferrer"{% endif %}>Source</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="versions-na">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% if row.version_obj.release_notes %}
|
||||
<tr class="versions-notes-row">
|
||||
<td colspan="{{ archive_specs|length|add:1 }}">
|
||||
<span class="versions-notes-label">Notes</span>
|
||||
<p class="versions-notes-body">{{ row.version_obj.release_notes }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<ul class="versions-package-list" role="list">
|
||||
{% for ver in archive_versions %}
|
||||
<li class="versions-package-card glass-card fade-in">
|
||||
<div class="versions-package-head">
|
||||
<div class="pkg-version-meta">
|
||||
<span class="download-version">{{ ver.version }}</span>
|
||||
{% include "products/_release_channel_badge.html" with version=ver %}
|
||||
</div>
|
||||
{% if ver.package_resource_url %}
|
||||
<a href="{{ ver.package_resource_url }}" class="btn-ghost btn--sm" target="_blank" rel="noopener noreferrer">{{ package_resource_button_text }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if ver.release_notes %}
|
||||
<p class="versions-notes-body">{{ ver.release_notes }}</p>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
@@ -18,13 +18,13 @@
|
||||
<ol class="breadcrumb-list" role="list">
|
||||
<li><a href="{% url 'pages:home' %}">Home</a></li>
|
||||
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
||||
<li><a href="{% url 'products:overview' %}">Products</a></li>
|
||||
<li><a href="{% url 'products:overview' %}">Projects</a></li>
|
||||
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
||||
<li aria-current="page">{{ main_product.name }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<div class="page-hero-content">
|
||||
<div class="section-badge">Product</div>
|
||||
<div class="section-badge">Project</div>
|
||||
<h1 class="page-hero-title" id="main-product-heading">{{ main_product.name }}</h1>
|
||||
<p class="page-hero-subtitle">{{ main_product.short_description }}</p>
|
||||
</div>
|
||||
@@ -48,7 +48,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include "products/_releases_section.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -56,7 +55,7 @@
|
||||
{% include "partials/_page_videos.html" with videos=product_videos %}
|
||||
|
||||
{% if articles %}
|
||||
<section class="section" aria-label="Product articles">
|
||||
<section class="section" aria-label="Project articles">
|
||||
<div class="container">
|
||||
{% include "products/_article_list.html" %}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Products{% endblock %}
|
||||
{% block title %}Projects{% endblock %}
|
||||
{% block meta_description %}Explore com2care tools for collaborative medical imaging, radiomics, reproducible workflows, and research communication.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -13,16 +13,16 @@
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="page-hero-content">
|
||||
<div class="section-badge">Software Suite</div>
|
||||
<h1 class="page-hero-title" id="products-overview-heading">Our Products</h1>
|
||||
<p class="page-hero-subtitle">Advanced tools for medical imaging research and radiomics analysis.</p>
|
||||
<div class="section-badge">Research Portfolio</div>
|
||||
<h1 class="page-hero-title" id="products-overview-heading">Our Projects</h1>
|
||||
<p class="page-hero-subtitle">Collaborative work in medical imaging research and radiomics analysis.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" aria-labelledby="products-list-heading">
|
||||
<div class="container">
|
||||
<h2 class="sr-only" id="products-list-heading">Product List</h2>
|
||||
<h2 class="sr-only" id="products-list-heading">Project List</h2>
|
||||
{% if main_products %}
|
||||
<div class="products-overview-grid">
|
||||
{% for product in main_products %}
|
||||
@@ -35,7 +35,7 @@
|
||||
<div class="product-overview-body">
|
||||
<h2 class="product-overview-title">{{ product.name }}</h2>
|
||||
<p class="product-overview-short">{{ product.short_description }}</p>
|
||||
<p class="product-overview-desc" data-readmore="110">{{ product.description }}</p>
|
||||
<div class="product-overview-desc rich-content" data-readmore="110">{{ product.rendered_description }}</div>
|
||||
<a href="{{ product.get_absolute_url }}" class="btn-primary">Explore {{ product.name }}</a>
|
||||
</div>
|
||||
{% with subs=product.sub_products.all %}
|
||||
@@ -64,7 +64,7 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state-block glass-card">
|
||||
<p>No products available at this time. Check back soon.</p>
|
||||
<p>No projects available at this time. Check back soon.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<ol class="breadcrumb-list" role="list">
|
||||
<li><a href="{% url 'pages:home' %}">Home</a></li>
|
||||
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
||||
<li><a href="{% url 'products:overview' %}">Products</a></li>
|
||||
<li><a href="{% url 'products:overview' %}">Projects</a></li>
|
||||
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
||||
<li><a href="{{ main_product.get_absolute_url }}">{{ main_product.name }}</a></li>
|
||||
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
||||
@@ -47,7 +47,6 @@
|
||||
<div class="rich-content" data-readmore="220">{{ sub_product.rendered_description }}</div>
|
||||
</div>
|
||||
|
||||
{% include "products/_releases_section.html" %}
|
||||
|
||||
{% include "partials/_page_videos.html" with videos=product_videos %}
|
||||
|
||||
@@ -72,7 +71,7 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="sidebar-empty">No other modules in this product.</p>
|
||||
<p class="sidebar-empty">No other modules in this project.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<a href="{{ main_product.get_absolute_url }}" class="btn-ghost sidebar-back-btn">
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Older releases — {% if sub_product %}{{ sub_product.name }} — {% endif %}{{ main_product.name }}{% endblock %}
|
||||
{% block meta_description %}Archived releases{% if sub_product %} for {{ sub_product.name }}{% else %} for {{ main_product.name }}{% endif %}.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<section class="page-hero" aria-labelledby="versions-heading">
|
||||
<div class="page-hero-blobs" aria-hidden="true">
|
||||
<div class="blob blob--1"></div>
|
||||
<div class="blob blob--2"></div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||
<ol class="breadcrumb-list" role="list">
|
||||
<li><a href="{% url 'pages:home' %}">Home</a></li>
|
||||
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
||||
<li><a href="{% url 'products:overview' %}">Products</a></li>
|
||||
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
||||
<li><a href="{{ main_product.get_absolute_url }}">{{ main_product.name }}</a></li>
|
||||
{% if sub_product %}
|
||||
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
||||
<li><a href="{{ sub_product.get_absolute_url }}">{{ sub_product.name }}</a></li>
|
||||
{% endif %}
|
||||
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
||||
<li aria-current="page">Older releases</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<div class="page-hero-content">
|
||||
<div class="section-badge">{% if sub_product %}{{ sub_product.name }}{% else %}{{ main_product.name }}{% endif %}</div>
|
||||
<h1 class="page-hero-title" id="versions-heading">Older releases</h1>
|
||||
<p class="page-hero-subtitle">Archived versions of this {% if sub_product %}module{% else %}product{% endif %}.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section versions-archive-section">
|
||||
<div class="container">
|
||||
{% include "products/_versions_archive_body.html" %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user