Compare commits

...

10 Commits

Author SHA1 Message Date
mohamad 4fc4282f0f fix: separate logo and hero icon 2026-08-03 16:29:25 +03:30
mohamad 04b6e9049e feat: pagination for videos and icon separating 2026-08-01 17:45:55 +03:30
mohamad 7cddc03a57 change release section put video and more 2026-06-08 17:08:43 +03:30
mohamad 33b3477176 feat: a link in download section 2026-06-07 17:41:41 +03:30
mohamad cbc49cd8ae feat: upload file in contact form 2026-06-07 17:09:04 +03:30
mohamad 76185c7434 feat: add upload and delete option for releases 2026-05-31 13:50:55 +03:30
mohamad de2789bd99 fix button words 2026-05-31 12:45:05 +03:30
mohamad 7726b0b6ec fix: responsive mega menu 2026-05-31 10:30:36 +03:30
mohamad 52516d5a5a fix: show product in replacement of sub products in homepage 2026-05-26 17:38:53 +03:30
mohamad cacf1ba77b feat: new dynamic pages in admin panel 2026-05-25 17:17:27 +03:30
101 changed files with 5810 additions and 962 deletions
+9 -8
View File
@@ -1,17 +1,18 @@
DJANGO_SETTINGS_MODULE=config.settings.production DJANGO_SETTINGS_MODULE=config.settings.production
DJANGO_SECRET_KEY=uqgb2wi9@1dr8alhhx$rp_tx!%_en$k7w6yjbu7wz-qr3$&3-w DJANGO_SECRET_KEY=replace-with-a-long-random-production-secret
DEBUG=False DEBUG=False
ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0,radiuma.com,www.radiuma.com ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0,com2care.com,www.com2care.com
CSRF_TRUSTED_ORIGINS=localhost,127.0.0.1,0.0.0.0,radiuma.com,www.radiuma.com CSRF_TRUSTED_ORIGINS=http://localhost:8000,https://com2care.com,https://www.com2care.com
POSTGRES_DB=radiuma POSTGRES_DB=com2care
POSTGRES_USER=radiuma_user POSTGRES_USER=com2care_user
POSTGRES_PASSWORD=eS4_WYJH97gywyoHjP6v POSTGRES_PASSWORD=replace-with-a-strong-database-password
POSTGRES_HOST=db POSTGRES_HOST=db
POSTGRES_PORT=5432 POSTGRES_PORT=5432
SECURE_SSL_REDIRECT=False SECURE_SSL_REDIRECT=False
DJANGO_SUPERUSER_USERNAME=admin DJANGO_SUPERUSER_USERNAME=admin
DJANGO_SUPERUSER_EMAIL=admin@yourdomain.com DJANGO_SUPERUSER_EMAIL=admin@com2care.com
DJANGO_SUPERUSER_PASSWORD=bS7_U_uRTmivj7W-lCR5 DJANGO_SUPERUSER_PASSWORD=cmosV6Tw46Odv7UN
DJANGO_SUPERUSER_SYNC_PASSWORD=False
+5
View File
@@ -46,5 +46,10 @@ venv.bak/
.dmypy.json .dmypy.json
media/ media/
private_uploads/
staticfiles/ staticfiles/
*.DS_Store *.DS_Store
test_media_releases/
docs/
scripts/
videos/
+125 -231
View File
@@ -1,278 +1,172 @@
# Radiuma Website # Communication to Care
Django MVT informational website for **Radiuma**, showcasing the **Radiuma** medical imaging and radiomics software suite. The Django website for **Communication to Care**, published at **com2care.com**. It presents the
com2care medical-imaging research platform, product modules, learning videos, downloads, FAQs,
and support information.
## Tech Stack ## Start with Docker
| Layer | Technology | Docker Compose includes working local defaults, database migrations, demo content, and the first
|---|---| admin account. No setup command or `.env` file is required for a local demonstration.
| Backend | Django 5.1 (MVT) |
| Database | PostgreSQL 16 | ```bash
| Static files | WhiteNoise (with Brotli compression) | docker compose up -d --build
| Application server | Gunicorn | ```
| Containerization | Docker + Docker Compose |
| Frontend | Vanilla HTML/CSS/JS (no framework) | Open:
## Project Structure - Website: <http://localhost:8000>
- Admin: <http://localhost:8000/admin/>
```
tecvico_website/ Initial local admin credentials:
├── config/ # Django project configuration
│ └── settings/ ```text
│ ├── base.py # Shared settings Username: admin
│ ├── development.py # Dev settings (DEBUG=True, dotenv) Password: cmosV6Tw46Odv7UN
│ └── production.py # Production settings (security headers) ```
├── apps/
│ ├── core/ # Context processors, management commands Change the password immediately after the first login using **Django Admin → Change password**.
│ │ └── management/commands/seed_content.py The startup process never resets an existing admin password unless credential synchronization is
│ ├── products/ # MainProduct, SubProduct, Article, ArticleSection explicitly enabled.
│ └── pages/ # FAQEntry, DownloadItem; static pages
├── templates/ # Global templates ## Automatic startup behavior
│ ├── base.html
│ ├── partials/ Every web-container start performs these idempotent steps:
│ └── pages/ & products/
├── static/ 1. Wait for PostgreSQL.
│ ├── css/main.css # Full design system (dark glass/ice theme) 2. Collect static files.
│ └── js/main.js # Navbar, FAQ accordion, scroll effects 3. Apply Django migrations.
├── Dockerfile 4. Normalize public database text and URLs to the Communication to Care identity.
├── docker-compose.yml # Production compose 5. Seed demonstration content only when all public content tables are empty.
├── docker-compose.override.yml # Development compose overrides 6. Create the configured admin account only when it does not exist.
└── entrypoint.sh # DB wait + migrate on container start 7. Start Django or Gunicorn.
```
Demo content includes:
## URL Map
- A com2care product with five medical-imaging modules.
| URL | View | Description | - Articles, structured specifications, FAQs, and release examples.
|---|---|---| - Homepage and About page sections with local images.
| `/` | `HomeView` | Landing page | - A generated Communication to Care hero image.
| `/about/` | `AboutView` | What is Radiuma | - Relevant external YouTube tutorials for DICOM review and image segmentation.
| `/products/` | `ProductOverviewView` | All main products | - `support@com2care.com` contact data.
| `/products/<main-slug>/` | `MainProductDetailView` | Main product + sub-products |
| `/products/<main-slug>/<sub-slug>/` | `SubProductDetailView` | Sub-product + articles | Content created or edited by an admin is preserved on later container restarts.
| `/downloads/` | `DownloadsView` | Download items by platform |
| `/faq/` | `FAQView` | FAQ entries | ## Optional configuration
| `/contact/` | `ContactView` | Contact info |
| `/admin/` | Django Admin | Admin panel | Copy `.env.example` to `.env` only when you want to override the local defaults:
## Data Model
```
MainProduct
└── SubProduct (FK → MainProduct)
└── Article (FK → SubProduct)
└── ArticleSection (FK → Article) ← key/value metadata
FAQEntry ← admin-managed FAQ items
DownloadItem ← admin-managed download links per platform
```
---
## Local Development (with Docker)
### Prerequisites
- Docker Desktop (or Docker Engine + Compose plugin)
### 1. Clone & configure
```bash ```bash
git clone <repo-url> tecvico_website
cd tecvico_website
cp .env.example .env cp .env.example .env
``` ```
Edit `.env` and set at minimum: Important variables:
``` | Variable | Purpose |
DJANGO_SECRET_KEY=your-very-secure-random-key |---|---|
POSTGRES_PASSWORD=choose-a-strong-password | `DJANGO_SECRET_KEY` | Required strong secret for production |
``` | `ALLOWED_HOSTS` | Hostnames, including `com2care.com` |
| `CSRF_TRUSTED_ORIGINS` | Full trusted origins with `https://` |
| `POSTGRES_DB` | PostgreSQL database name |
| `POSTGRES_USER` | PostgreSQL user |
| `POSTGRES_PASSWORD` | PostgreSQL password |
| `DJANGO_SUPERUSER_ENABLED` | Set `False` to disable automatic admin creation |
| `DJANGO_SUPERUSER_USERNAME` | First admin username |
| `DJANGO_SUPERUSER_EMAIL` | First admin email |
| `DJANGO_SUPERUSER_PASSWORD` | First admin password |
| `DJANGO_SUPERUSER_SYNC_PASSWORD` | Set `True` for one restart to rotate an existing admin password from environment values |
### 2. Start services (development mode) For a server-side password rotation, set the new `DJANGO_SUPERUSER_PASSWORD`, temporarily set
`DJANGO_SUPERUSER_SYNC_PASSWORD=True`, restart the web service once, then restore it to `False`.
An admin can always change their own password through Django Admin without changing server
configuration.
The `docker-compose.override.yml` automatically activates when you run `docker compose up`, mounting the source code and using the development settings. ## Production
Use strong values in `.env`, terminate TLS with a reverse proxy, and start only the production
Compose file:
```bash ```bash
docker compose up --build docker compose -f docker-compose.yml up -d --build
``` ```
The app is available at **http://localhost:8000** At minimum, replace these local defaults in production:
### 3. Create a superuser - `DJANGO_SECRET_KEY`
- `POSTGRES_PASSWORD`
- `DJANGO_SUPERUSER_PASSWORD`
- `SECURE_SSL_REDIRECT=True`
```bash The default production host configuration already includes `com2care.com` and
docker compose exec web python manage.py createsuperuser `www.com2care.com`.
```
### 4. Seed initial content ## Manual development
Populate the database with content scraped and adapted from visera.ca: Requirements:
```bash
docker compose exec web python manage.py seed_content
```
To flush and re-seed from scratch:
```bash
docker compose exec web python manage.py seed_content --flush
```
---
## Local Development (without Docker)
### Prerequisites
- Python 3.12+ - Python 3.12+
- PostgreSQL 14+ - PostgreSQL 14+
### 1. Set up virtual environment
```bash ```bash
python -m venv .venv python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate source .venv/bin/activate
pip install -r requirements-dev.txt pip install -r requirements-dev.txt
```
### 2. Configure environment
```bash
cp .env.example .env cp .env.example .env
```
Edit `.env`:
```
DJANGO_SECRET_KEY=your-key
POSTGRES_DB=tecvico
POSTGRES_USER=your_pg_user
POSTGRES_PASSWORD=your_pg_password
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
```
### 3. Create the database
```bash
createdb tecvico
```
### 4. Run migrations & seed
```bash
python manage.py migrate python manage.py migrate
python manage.py seed_content python manage.py seed_content --if-empty
python manage.py createsuperuser python manage.py ensure_superuser
```
### 5. Start development server
```bash
python manage.py runserver python manage.py runserver
``` ```
--- ## Management commands
## Running Tests ```bash
# Add demo data only to a completely empty public database
python manage.py seed_content --if-empty
### With Django test runner # Intentionally replace all public content with the demo dataset
python manage.py seed_content --flush
# Normalize existing public text, slugs, URLs, and email addresses
python manage.py normalize_brand
# Create or optionally synchronize the configured admin
python manage.py ensure_superuser
```
## Tests
```bash ```bash
python manage.py test apps python manage.py test apps
``` ```
### With pytest (requires `requirements-dev.txt`) or:
```bash ```bash
pytest pytest
``` ```
### With coverage report ## Project structure
```bash ```text
coverage run -m pytest config/ Django settings and root URLs
coverage report -m apps/core/ Branding, contact settings, bootstrap commands
coverage html # Generates htmlcov/index.html apps/pages/ Home, About, FAQ, Contact, custom pages, videos
apps/products/ Products, articles, releases, product videos
templates/ Django templates
static/css/main.css Site design system
static/images/ com2care brand and editorial assets
static/js/main.js Navigation and interaction behavior
docker-compose.yml PostgreSQL and production web service
docker-compose.override.yml Local development override
entrypoint.sh Automated database and content bootstrap
``` ```
--- ## Technology
## Production Deployment - Django
- PostgreSQL 16
### 1. Build and start production containers - Gunicorn
- WhiteNoise
Remove `docker-compose.override.yml` (or don't override it) and pass production environment variables: - Docker Compose
- Vanilla HTML, CSS, and JavaScript
```bash
DJANGO_SETTINGS_MODULE=config.settings.production \
docker compose -f docker-compose.yml up --build -d
```
### 2. Production `.env` checklist
| Variable | Notes |
|---|---|
| `DJANGO_SECRET_KEY` | Use `python -c "import secrets; print(secrets.token_urlsafe(50))"` |
| `DEBUG` | Must be `False` |
| `ALLOWED_HOSTS` | Comma-separated: `yourdomain.com,www.yourdomain.com` |
| `CSRF_TRUSTED_ORIGINS` | `https://yourdomain.com` |
| `POSTGRES_PASSWORD` | Strong random password |
| `SECURE_SSL_REDIRECT` | `True` when behind TLS termination |
### 3. Reverse proxy (recommended)
Place an Nginx or Caddy reverse proxy in front of Gunicorn for TLS termination and serving static files (or let WhiteNoise handle statics directly).
---
## Admin Panel
Access Django Admin at `/admin/` with superuser credentials.
### What you can manage
| Model | Description |
|---|---|
| **Main Products** | Top-level products with nested sub-products inline |
| **Sub Products** | Modules within a main product; articles editable inline |
| **Articles** | Article entries with section key/values inline |
| **Article Sections** | Individual key-value metadata rows |
| **FAQ Entries** | Accordion FAQ items (order, active toggle) |
| **Download Items** | Platform download links (Windows/macOS/Linux) |
---
## Seed Content
The `seed_content` command populates:
- **Radiuma** (MainProduct) with 5 sub-products:
- Image Processing
- Radiomics Features
- Medical Image Visualization
- Format Conversion
- Workflow Management
- Articles and sections for each sub-product
- 6 FAQ entries
- 3 download items (Windows active, macOS/Linux coming soon)
Content is adapted from the original [visera.ca](https://visera.ca) website.
---
## Design System
The website uses a custom dark-glass design combining:
- **visera.ca** aesthetic — dark background, organic blob animations, blue/teal accents
- **Apple visionOS Ice** aesthetic — frosted glass panels (`backdrop-filter: blur`), translucent cards, soft gradients
Key CSS custom properties are in `static/css/main.css` under `:root`.
---
## License
Content is adapted from visera.ca under CC BY-NC-SA. Software code is proprietary to Radiuma.
+20 -3
View File
@@ -8,19 +8,36 @@ from .models import SiteBranding, SiteContact
@admin.register(SiteBranding) @admin.register(SiteBranding)
class SiteBrandingAdmin(admin.ModelAdmin): class SiteBrandingAdmin(admin.ModelAdmin):
fieldsets = ( fieldsets = (
("Icon", {"fields": ("icon", "icon_alt")}), (
"Brand assets",
{
"fields": (
"icon",
"icon_alt",
"website_icon",
"hero_logo",
"hero_logo_alt",
),
"description": (
"Manage each placement independently. Removing an upload restores "
"the existing Communication to Care asset for that placement."
),
},
),
( (
"Sizes", "Sizes",
{ {
"fields": ("navbar_icon_size", "footer_icon_size"), "fields": ("navbar_icon_size", "footer_icon_size"),
"description": "Square dimensions in pixels for each placement.", "description": (
"Set the logo height for each placement. Its width is calculated "
"automatically so the uploaded image is never cropped or stretched."
),
}, },
), ),
( (
"Appearance", "Appearance",
{ {
"fields": ( "fields": (
"object_fit",
"show_border", "show_border",
"border_width", "border_width",
"border_color", "border_color",
+30
View File
@@ -0,0 +1,30 @@
from django.utils.html import format_html
from apps.core.video import VIDEO_SOURCE_UPLOAD, VIDEO_SOURCE_YOUTUBE
def video_admin_preview(obj):
if not obj.has_video:
return "No video configured yet."
if obj.video_source == VIDEO_SOURCE_UPLOAD and obj.video_file:
if obj.video_poster:
return format_html(
'<video controls playsinline preload="metadata" style="max-width:100%;" poster="{}">'
'<source src="{}"></video>',
obj.video_poster.url,
obj.video_file.url,
)
return format_html(
'<video controls playsinline preload="metadata" style="max-width:100%;">'
'<source src="{}"></video>',
obj.video_file.url,
)
if obj.video_source == VIDEO_SOURCE_YOUTUBE and obj.youtube_embed_url:
return format_html(
'<iframe src="{}" title="Preview" width="480" height="270" '
'style="max-width:100%;border:0;" allowfullscreen loading="lazy"></iframe>',
obj.youtube_embed_url,
)
return "No video configured yet."
video_admin_preview.short_description = "Preview"
+6
View File
@@ -1,6 +1,7 @@
from django.db.models import Prefetch from django.db.models import Prefetch
from apps.core.models import SiteBranding, SiteContact from apps.core.models import SiteBranding, SiteContact
from apps.pages.models import CustomPage
from apps.products.models import MainProduct, SubProduct from apps.products.models import MainProduct, SubProduct
@@ -28,8 +29,13 @@ def navigation(request):
all_footer_products = list(main_products[:5]) all_footer_products = list(main_products[:5])
footer_main_products = all_footer_products[:4] footer_main_products = all_footer_products[:4]
footer_main_products_has_more = len(all_footer_products) == 5 footer_main_products_has_more = len(all_footer_products) == 5
nav_custom_pages = CustomPage.objects.filter(
is_published=True,
show_in_nav=True,
).order_by("menu_order", "title")
return { return {
"nav_main_products": main_products, "nav_main_products": main_products,
"nav_custom_pages": nav_custom_pages,
"footer_main_products": footer_main_products, "footer_main_products": footer_main_products,
"footer_main_products_has_more": footer_main_products_has_more, "footer_main_products_has_more": footer_main_products_has_more,
} }
@@ -4,29 +4,53 @@ from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
DEFAULT_ADMIN_PASSWORD = "cmosV6Tw46Odv7UN"
class Command(BaseCommand): class Command(BaseCommand):
help = "Create a superuser from environment variables if one does not already exist." help = "Create the first admin account from environment variables when needed."
def handle(self, *args, **options): def handle(self, *args, **options):
User = get_user_model() User = get_user_model()
username = os.environ.get("DJANGO_SUPERUSER_USERNAME", "admin") enabled = os.environ.get("DJANGO_SUPERUSER_ENABLED", "True").lower()
email = os.environ.get("DJANGO_SUPERUSER_EMAIL", "admin@radiuma.com") if enabled in {"0", "false", "no", "off"}:
password = os.environ.get("DJANGO_SUPERUSER_PASSWORD") self.stdout.write("Automatic admin creation is disabled.")
return
if not password: username = os.environ.get("DJANGO_SUPERUSER_USERNAME", "admin")
email = os.environ.get("DJANGO_SUPERUSER_EMAIL", "admin@com2care.com")
password = os.environ.get("DJANGO_SUPERUSER_PASSWORD", DEFAULT_ADMIN_PASSWORD)
sync_password = os.environ.get(
"DJANGO_SUPERUSER_SYNC_PASSWORD", "False"
).lower() in {"1", "true", "yes", "on"}
user = User.objects.filter(username=username).first()
if user:
if sync_password:
user.email = email
user.is_staff = True
user.is_superuser = True
user.set_password(password)
user.save(
update_fields=["email", "is_staff", "is_superuser", "password"]
)
self.stdout.write( self.stdout.write(
self.style.WARNING( self.style.SUCCESS(
"DJANGO_SUPERUSER_PASSWORD is not set — skipping superuser creation." f"Admin '{username}' credentials synchronized from the environment."
) )
) )
return return
if User.objects.filter(username=username).exists():
self.stdout.write( self.stdout.write(
self.style.SUCCESS(f"Superuser '{username}' already exists — skipping.") self.style.SUCCESS(
f"Admin '{username}' already exists; its password was preserved."
)
) )
return return
User.objects.create_superuser(username=username, email=email, password=password) User.objects.create_superuser(username=username, email=email, password=password)
self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' created successfully.")) self.stdout.write(
self.style.SUCCESS(
f"Admin '{username}' created. Change the password in Django Admin after first login."
)
)
@@ -0,0 +1,68 @@
import re
from django.apps import apps
from django.core.management.base import BaseCommand
from django.db import models, transaction
LEGACY_NAME = "".join(("Radi", "uma"))
LEGACY_PATTERN = re.compile(re.escape(LEGACY_NAME), re.IGNORECASE)
LEGACY_DOMAIN_PATTERN = re.compile(
rf"{re.escape(LEGACY_NAME)}\.com", re.IGNORECASE
)
PUBLIC_APP_LABELS = frozenset({"core", "pages", "products"})
def branded_value(field, value):
if not isinstance(value, str) or not LEGACY_PATTERN.search(value):
return value
value = LEGACY_DOMAIN_PATTERN.sub("com2care.com", value)
if isinstance(field, (models.EmailField, models.URLField, models.SlugField)):
return LEGACY_PATTERN.sub("com2care", value)
return LEGACY_PATTERN.sub("Communication to Care", value)
class Command(BaseCommand):
help = "Normalize legacy public content to the current com2care identity."
@transaction.atomic
def handle(self, *args, **options):
updated_rows = 0
for model in apps.get_models():
if model._meta.app_label not in PUBLIC_APP_LABELS:
continue
text_fields = [
field
for field in model._meta.concrete_fields
if isinstance(field, (models.CharField, models.TextField))
and not field.primary_key
]
if not text_fields:
continue
field_names = [field.name for field in text_fields]
for instance in model._default_manager.all().only("pk", *field_names).iterator():
changed_fields = []
for field in text_fields:
current = getattr(instance, field.name)
updated = branded_value(field, current)
if updated == current:
continue
if isinstance(field, models.SlugField):
conflict = model._default_manager.exclude(pk=instance.pk).filter(
**{field.name: updated}
).exists()
if conflict:
continue
setattr(instance, field.name, updated)
changed_fields.append(field.name)
if changed_fields:
instance.save(update_fields=changed_fields)
updated_rows += 1
if updated_rows:
self.stdout.write(
self.style.SUCCESS(f"Normalized {updated_rows} public content row(s).")
)
else:
self.stdout.write("Public content already uses the com2care identity.")
+356 -165
View File
@@ -1,26 +1,42 @@
from django.conf import settings
from django.core.files import File
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import transaction from django.db import transaction
from apps.core.models import SiteContact from apps.core.models import SiteBranding, SiteContact
from apps.pages.models import DownloadItem, FAQEntry, HeroSection, HomepageSection, HomepageSectionItem from apps.pages.models import (
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct AboutSection,
AboutSectionItem,
DownloadItem,
FAQEntry,
HeroSection,
HomepageSection,
HomepageSectionItem,
PageVideo,
)
from apps.products.models import (
Article,
ArticleSection,
MainProduct,
ProductVideo,
SubProduct,
)
MAIN_PRODUCTS = [ MAIN_PRODUCTS = [
{ {
"name": "Radiuma", "name": "Communication to Care",
"slug": "radiuma", "slug": "com2care",
"short_description": "Visualized & Standardized Environment for Radiomics Analysis", "short_description": "Collaborative, standardized medical imaging research workflows",
"description": ( "description": (
"Radiuma is a free, open-source software specialized for visualization, " "Communication to Care (com2care) brings medical-image visualization, processing, "
"processing, segmentation, registration, fusion and analysis of medical and " "segmentation, registration, fusion, radiomics, and machine-learning workflows into "
"biomedical images, including radiomics and machine learning analysis. " "one research environment. The platform is designed to help multidisciplinary teams "
"Radiuma is a major, entirely-revamped upgrade to the original SERA " "discuss findings clearly, build repeatable pipelines, and share analysis context. "
"(Matlab-based), now built on Python for broader accessibility and community " "Its quantitative imaging workflow follows IBSI guidance for reproducible research."
"contribution. It enables standardized and reproducible radiomic feature "
"extraction in compliance with the Image Biomarker Standardization Initiative "
"(IBSI 1.0), and implements image filters standardized against IBSI 2.0."
), ),
"order": 1, "order": 1,
"show_on_homepage": True,
"homepage_order": 1,
"sub_products": [ "sub_products": [
{ {
"name": "Image Processing", "name": "Image Processing",
@@ -29,18 +45,16 @@ MAIN_PRODUCTS = [
"description": ( "description": (
"Advanced image processing capabilities including standardized filtering " "Advanced image processing capabilities including standardized filtering "
"techniques compliant with IBSI 2.0, image registration, fusion, and " "techniques compliant with IBSI 2.0, image registration, fusion, and "
"Standardized Uptake Value (SUV) conversion. Radiuma employs popular " "Standardized Uptake Value (SUV) conversion. Communication to Care employs popular "
"image processing algorithms to create end-to-end standardized workflows " "image processing algorithms to create end-to-end standardized workflows "
"for consistent, reproducible research outcomes." "for consistent, reproducible research outcomes."
), ),
"order": 1, "order": 1,
"show_on_homepage": True,
"homepage_order": 1,
"articles": [ "articles": [
{ {
"title": "Image Filtering Techniques", "title": "Image Filtering Techniques",
"description": ( "description": (
"Radiuma implements a comprehensive set of image filtering techniques " "Communication to Care implements a comprehensive set of image filtering techniques "
"fully standardized against the Image Biomarker Standardization " "fully standardized against the Image Biomarker Standardization "
"Initiative (IBSI) phase 2. These filters enable reproducible " "Initiative (IBSI) phase 2. These filters enable reproducible "
"preprocessing across institutions and studies." "preprocessing across institutions and studies."
@@ -53,13 +67,13 @@ MAIN_PRODUCTS = [
"value": "Mean, Gaussian, Laplacian of Gaussian (LoG), Laws kernels, Gabor, Wavelets (PyWavelets), Log-Sigma", "value": "Mean, Gaussian, Laplacian of Gaussian (LoG), Laws kernels, Gabor, Wavelets (PyWavelets), Log-Sigma",
"order": 2, "order": 2,
}, },
{"title": "Author", "value": "Radiuma R&D Team", "order": 3}, {"title": "Author", "value": "Communication to Care R&D Team", "order": 3},
], ],
}, },
{ {
"title": "Image Registration & Fusion", "title": "Image Registration & Fusion",
"description": ( "description": (
"Radiuma provides robust image registration and fusion methods, " "Communication to Care provides robust image registration and fusion methods, "
"enabling multi-modal image alignment for PET/CT, PET/MRI, and " "enabling multi-modal image alignment for PET/CT, PET/MRI, and "
"other combined modality studies. Standardized Uptake Value (SUV) " "other combined modality studies. Standardized Uptake Value (SUV) "
"conversion is also supported." "conversion is also supported."
@@ -78,7 +92,7 @@ MAIN_PRODUCTS = [
"slug": "radiomics-features", "slug": "radiomics-features",
"short_description": "IBSI 1.0 compliant handcrafted radiomic feature extraction", "short_description": "IBSI 1.0 compliant handcrafted radiomic feature extraction",
"description": ( "description": (
"Radiuma provides comprehensive handcrafted radiomic feature extraction " "Communication to Care provides comprehensive handcrafted radiomic feature extraction "
"fully standardized by the Image Biomarker Standardization Initiative " "fully standardized by the Image Biomarker Standardization Initiative "
"(IBSI 1.0). Features are computed from segmented regions of interest " "(IBSI 1.0). Features are computed from segmented regions of interest "
"across multiple image modalities, enabling reproducible quantitative " "across multiple image modalities, enabling reproducible quantitative "
@@ -89,7 +103,7 @@ MAIN_PRODUCTS = [
{ {
"title": "IBSI Compliant Feature Extraction", "title": "IBSI Compliant Feature Extraction",
"description": ( "description": (
"Radiuma computes a comprehensive set of radiomic features " "Communication to Care computes a comprehensive set of radiomic features "
"covering all IBSI 1.0 feature classes. Features are extracted " "covering all IBSI 1.0 feature classes. Features are extracted "
"from segmented Regions of Interest (ROIs) and are fully " "from segmented Regions of Interest (ROIs) and are fully "
"reproducible across different platforms and institutions." "reproducible across different platforms and institutions."
@@ -113,7 +127,7 @@ MAIN_PRODUCTS = [
"slug": "medical-image-visualization", "slug": "medical-image-visualization",
"short_description": "Professional multi-modality medical image viewer", "short_description": "Professional multi-modality medical image viewer",
"description": ( "description": (
"Radiuma includes a professional medical image viewer that supports " "Communication to Care includes a professional medical image viewer that supports "
"multiple imaging modalities and file formats. The viewer provides " "multiple imaging modalities and file formats. The viewer provides "
"comfortable, intuitive controls for slice navigation, windowing, " "comfortable, intuitive controls for slice navigation, windowing, "
"zoom, and annotation, suitable for radiation oncologists, radiologists, " "zoom, and annotation, suitable for radiation oncologists, radiologists, "
@@ -143,7 +157,7 @@ MAIN_PRODUCTS = [
"slug": "format-conversion", "slug": "format-conversion",
"short_description": "Professional converter for medical imaging file formats", "short_description": "Professional converter for medical imaging file formats",
"description": ( "description": (
"Radiuma provides a professional image format converter supporting all " "Communication to Care provides a professional image format converter supporting all "
"major medical imaging standards. Seamlessly convert between DICOM, " "major medical imaging standards. Seamlessly convert between DICOM, "
"NIFTI, NRRD, MHA, and other formats without loss of spatial metadata " "NIFTI, NRRD, MHA, and other formats without loss of spatial metadata "
"or patient information integrity." "or patient information integrity."
@@ -172,7 +186,7 @@ MAIN_PRODUCTS = [
"slug": "workflow-management", "slug": "workflow-management",
"short_description": "Reproducible research workflow creation and sharing", "short_description": "Reproducible research workflow creation and sharing",
"description": ( "description": (
"Radiuma's workflow management system allows researchers to design, save, " "Communication to Care's workflow management system allows researchers to design, save, "
"share, and reuse analysis pipelines. Workflows connect individual " "share, and reuse analysis pipelines. Workflows connect individual "
"processing steps — from image loading and preprocessing to feature " "processing steps — from image loading and preprocessing to feature "
"extraction and machine learning — into reproducible, shareable sequences " "extraction and machine learning — into reproducible, shareable sequences "
@@ -202,34 +216,28 @@ MAIN_PRODUCTS = [
FAQ_ENTRIES = [ FAQ_ENTRIES = [
{ {
"question": "What is the Radiuma license?", "question": "How is Communication to Care licensed?",
"answer": ( "answer": (
"Radiuma is free and open-source for research purposes.\n\n" "Licensing and deployment terms are provided with each com2care release. "
"License: CC BY-NC-SA (Creative Commons Attribution-NonCommercial-ShareAlike). " "Contact support@com2care.com for research, institutional, or evaluation access."
"This means you may use, share, and adapt the software for non-commercial "
"research purposes, provided you give appropriate credit and distribute "
"derivatives under the same license."
), ),
"order": 1, "order": 1,
}, },
{ {
"question": "How do I cite Radiuma in my research?", "question": "How do I acknowledge Communication to Care in my research?",
"answer": ( "answer": (
"Please cite the following reference if you publish results obtained with " "Mention Communication to Care (com2care) and the software version used in your "
"the help of Radiuma:\n\n" "methods section. Release-specific citation guidance can be requested from "
"M. R. Salmanpour, I. Shiri, M. Hosseinzadeh, H. Zaidi, S. Ashrafinia, " "support@com2care.com."
"M. Oveisi, A. Rahmim. Radiuma: Visualized & Standardized Environment for "
"Radiomics Analysis — A Shareable, Executable, and Reproducible Workflow "
"Generator. Proc. IEEE Medical Imaging Conference, 2023."
), ),
"order": 2, "order": 2,
}, },
{ {
"question": "Which operating systems does Radiuma support?", "question": "Which operating systems does Communication to Care support?",
"answer": ( "answer": (
"Radiuma currently fully supports Windows 10 and above (64-bit). " "Communication to Care currently fully supports Windows 10 and above (64-bit). "
"New versions to support macOS and Linux systems are under active development " "macOS and Linux packages are represented in this demonstration dataset as upcoming "
"and coming soon. Follow our Discord or check each product module page for updates." "channels. Check each product module page for current release information."
), ),
"order": 3, "order": 3,
}, },
@@ -244,9 +252,9 @@ FAQ_ENTRIES = [
"order": 4, "order": 4,
}, },
{ {
"question": "Is Radiuma suitable for clinical use?", "question": "Is Communication to Care suitable for clinical use?",
"answer": ( "answer": (
"Radiuma is designed and intended exclusively for research purposes. " "Communication to Care is designed and intended exclusively for research purposes. "
"It is not certified for clinical diagnostic use. Always consult with " "It is not certified for clinical diagnostic use. Always consult with "
"qualified medical professionals for clinical decisions." "qualified medical professionals for clinical decisions."
), ),
@@ -255,9 +263,8 @@ FAQ_ENTRIES = [
{ {
"question": "Where can I get support or report issues?", "question": "Where can I get support or report issues?",
"answer": ( "answer": (
"Support is available via email and through our community Discord server " "Use the contact form or email support@com2care.com. Include the software version, "
"(see the Contact page for current details). For bug reports and feature " "operating system, a short reproduction description, and non-sensitive logs when relevant."
"requests, please use the Discord forum or contact us directly by email."
), ),
"order": 6, "order": 6,
}, },
@@ -282,9 +289,42 @@ HOMEPAGE_SECTIONS = [
{ {
"section_type": HomepageSection.TYPE_SCREENSHOTS, "section_type": HomepageSection.TYPE_SCREENSHOTS,
"badge": "Gallery", "badge": "Gallery",
"title": "See Radiuma in Action", "title": "See Communication to Care in Action",
"description": "Explore Radiuma's powerful interface, workflow builder, and multi-modal image viewer.", "description": "Explore Communication to Care's powerful interface, workflow builder, and multi-modal image viewer.",
"order": 2, "order": 2,
"items": [
{
"title": "Visual workflow builder",
"content": "Connect image-processing steps into a repeatable analysis pipeline.",
"static_image": "screenshot-3.png",
"order": 1,
},
{
"title": "Multi-planar image review",
"content": "Inspect imaging and segmentation context across synchronized views.",
"static_image": "screenshot-4.png",
"order": 2,
},
{
"title": "Radiomics configuration",
"content": "Review quantitative feature settings before a reproducible run.",
"static_image": "screenshot-5.png",
"order": 3,
},
],
},
{
"section_type": HomepageSection.TYPE_VIDEO,
"badge": "Learning Library",
"title": "Medical image segmentation essentials",
"description": (
"A practical introduction to thresholding, drawing, erasing, and 3D review in a "
"medical-image segmentation workflow."
),
"video_url": "https://www.youtube.com/watch?v=_9J3i883yA4",
"video_styled_background": True,
"video_size": "lg",
"order": 3,
"items": [], "items": [],
}, },
{ {
@@ -292,19 +332,19 @@ HOMEPAGE_SECTIONS = [
"badge": "Our Software", "badge": "Our Software",
"title": "Products", "title": "Products",
"description": "Explore our suite of medical imaging and radiomics tools.", "description": "Explore our suite of medical imaging and radiomics tools.",
"order": 3, "order": 4,
"items": [], "items": [],
}, },
{ {
"section_type": HomepageSection.TYPE_PROBLEMS, "section_type": HomepageSection.TYPE_PROBLEMS,
"badge": "Value Proposition", "badge": "Value Proposition",
"title": "What Problems Does Radiuma Solve?", "title": "What Problems Does Communication to Care Solve?",
"description": "", "description": "",
"order": 4, "order": 5,
"items": [ "items": [
{"icon": "01", "title": "Accessibility", "content": "Radiuma provides a user-friendly interface and a wide range of tools, allowing researchers to perform complex data analysis without extensive technical knowledge or programming expertise.", "order": 1}, {"icon": "01", "title": "Accessibility", "content": "Communication to Care provides a user-friendly interface and a wide range of tools, allowing researchers to perform complex data analysis without extensive technical knowledge or programming expertise.", "order": 1},
{"icon": "02", "title": "Integrated Tools", "content": "Radiuma integrates a vast collection of tools and resources from various domains of healthcare and medical imaging research in a common, unified environment.", "order": 2}, {"icon": "02", "title": "Integrated Tools", "content": "Communication to Care integrates a vast collection of tools and resources from various domains of healthcare and medical imaging research in a common, unified environment.", "order": 2},
{"icon": "03", "title": "Flexibility", "content": "Radiuma offers flexibility in terms of tool optimization and workflow customization to match your specific research requirements.", "order": 3}, {"icon": "03", "title": "Flexibility", "content": "Communication to Care offers flexibility in terms of tool optimization and workflow customization to match your specific research requirements.", "order": 3},
{"icon": "04", "title": "Reproducibility", "content": "Improve usability, reusability, and reproducibility (URR) through a workflow management system that allows researchers to easily create, share, and reuse analysis pipelines.", "order": 4}, {"icon": "04", "title": "Reproducibility", "content": "Improve usability, reusability, and reproducibility (URR) through a workflow management system that allows researchers to easily create, share, and reuse analysis pipelines.", "order": 4},
], ],
}, },
@@ -313,48 +353,112 @@ HOMEPAGE_SECTIONS = [
"badge": "Our Story", "badge": "Our Story",
"title": "More to Know", "title": "More to Know",
"description": ( "description": (
"Radiuma has been developing since 2021 by the Quantitative Radiomolecular Imaging " "Communication to Care is shaped around multidisciplinary research: connect imaging "
"and Therapy (Qurit) lab & program at the University of British Columbia & " "evidence, analysis steps, and team discussion in one understandable workflow."
"BC Cancer Research Institute, Vancouver, BC, Canada."
), ),
"link_text": "Learn More", "link_text": "Learn More",
"link_url": "/about/", "link_url": "/about/",
"order": 5, "order": 6,
"items": [], "items": [],
}, },
{ {
"section_type": HomepageSection.TYPE_SUPPORTERS, "section_type": HomepageSection.TYPE_SUPPORTERS,
"badge": "Acknowledgements", "badge": "Who It Serves",
"title": "Our Supporters", "title": "Built for Collaborative Teams",
"description": "Radiuma is made possible by the support of leading research institutions and organizations.", "description": "com2care demo workflows are organized around the people who review, analyze, and communicate medical-imaging evidence.",
"order": 6, "order": 7,
"items": [ "items": [
{ {
"title": "University of British Columbia", "title": "Imaging Researchers",
"content": "Faculty of Medicine and the Department of Integrative Oncology.", "content": "Build standardized pipelines and retain the context behind each processing decision.",
"order": 1, "order": 1,
}, },
{ {
"title": "BC Cancer Research Institute", "title": "Clinical Research Teams",
"content": "Supporting cutting-edge radiomics and medical imaging research in Vancouver, BC.", "content": "Review images and quantitative results together without presenting research output as diagnosis.",
"order": 2, "order": 2,
}, },
], ],
}, },
] ]
ABOUT_SECTIONS = [
{
"section_type": AboutSection.TYPE_HERO,
"badge": "Our Mission",
"title": "Better imaging conversations, clearer research decisions",
"subtitle": "Communication to Care",
"content": (
"com2care is built around a simple idea: complex medical-imaging evidence becomes "
"more useful when researchers, clinicians, engineers, and data teams can examine it "
"together in a shared, reproducible workflow."
),
"order": 1,
"items": [
{
"title": "A collaborative care and research team",
"image_alt": "Healthcare and imaging researchers reviewing medical images together",
"static_image": "com2care-care-team.jpg",
"is_featured": True,
"order": 1,
}
],
},
{
"section_type": AboutSection.TYPE_GRID,
"badge": "How We Work",
"title": "Designed for shared understanding",
"content": "Every part of the platform supports transparent, repeatable research communication.",
"order": 2,
"items": [
{
"icon": "01",
"title": "Clinical context",
"content": "Keep imaging evidence and analysis choices visible to the whole team.",
"order": 1,
},
{
"icon": "02",
"title": "Reproducible workflows",
"content": "Save processing steps so collaborators can review and repeat the same pipeline.",
"order": 2,
},
{
"icon": "03",
"title": "Responsible research",
"content": "Separate research exploration from clinical diagnosis and protect patient privacy.",
"order": 3,
},
],
},
{
"section_type": AboutSection.TYPE_VIDEO,
"badge": "Practical Learning",
"title": "Viewing DICOM studies with an open medical-imaging workflow",
"content": (
"This independent tutorial demonstrates how researchers can import and inspect DICOM "
"studies in 3D Slicer—skills that complement the workflows presented on com2care."
),
"video_url": "https://www.youtube.com/watch?v=EV8tAjAHeac",
"video_styled_background": True,
"video_size": "lg",
"order": 3,
"items": [],
},
]
DOWNLOAD_ITEMS = [ DOWNLOAD_ITEMS = [
{ {
"name": "Radiuma Desktop", "name": "com2care Desktop",
"platform": "windows", "platform": "windows",
"version": "1.0.0", "version": "1.0.0",
"download_url": "https://github.com/radiuma/radiuma/releases/latest/download/Radiuma-Setup.exe", "download_url": "https://com2care.com/downloads/",
"description": "Windows 10 and above (64-bit). Installer package.", "description": "Demonstration release channel for Windows 10 and above (64-bit).",
"is_active": True, "is_active": True,
"order": 1, "order": 1,
}, },
{ {
"name": "Radiuma Desktop", "name": "com2care Desktop",
"platform": "macos", "platform": "macos",
"version": "Coming Soon", "version": "Coming Soon",
"download_url": "#", "download_url": "#",
@@ -363,7 +467,7 @@ DOWNLOAD_ITEMS = [
"order": 2, "order": 2,
}, },
{ {
"name": "Radiuma Desktop", "name": "com2care Desktop",
"platform": "linux", "platform": "linux",
"version": "Coming Soon", "version": "Coming Soon",
"download_url": "#", "download_url": "#",
@@ -375,19 +479,43 @@ DOWNLOAD_ITEMS = [
class Command(BaseCommand): class Command(BaseCommand):
help = "Seed the database with initial Radiuma / Radiuma content from radiuma.com" help = "Seed a complete com2care demonstration site."
CONTENT_MODELS = (
MainProduct,
FAQEntry,
DownloadItem,
HeroSection,
HomepageSection,
AboutSection,
PageVideo,
ProductVideo,
)
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument( parser.add_argument(
"--flush", "--flush",
action="store_true", action="store_true",
help="Delete all existing seed data before re-seeding", help="Delete existing public content before re-seeding.",
)
parser.add_argument(
"--if-empty",
action="store_true",
help="Seed only when every public content table is empty.",
) )
@transaction.atomic @transaction.atomic
def handle(self, *args, **options): def handle(self, *args, **options):
if options["if_empty"] and not options["flush"] and self._content_exists():
self.stdout.write("Public content already exists; demo seed skipped.")
return
if options["flush"]: if options["flush"]:
self.stdout.write("Flushing existing seed data...") self.stdout.write("Flushing existing public content...")
ProductVideo.objects.all().delete()
PageVideo.objects.all().delete()
AboutSectionItem.objects.all().delete()
AboutSection.objects.all().delete()
ArticleSection.objects.all().delete() ArticleSection.objects.all().delete()
Article.objects.all().delete() Article.objects.all().delete()
SubProduct.objects.all().delete() SubProduct.objects.all().delete()
@@ -398,70 +526,96 @@ class Command(BaseCommand):
HomepageSection.objects.all().delete() HomepageSection.objects.all().delete()
HeroSection.objects.all().delete() HeroSection.objects.all().delete()
self._seed_products() main_product = self._seed_products()
self._seed_faq() self._seed_faq()
self._seed_downloads() self._seed_downloads()
self._seed_homepage_sections() self._seed_homepage_sections()
self._seed_about_sections()
self._seed_product_videos(main_product)
self._seed_hero() self._seed_hero()
self._seed_site_branding()
self._seed_site_contact() self._seed_site_contact()
self.stdout.write(self.style.SUCCESS("Content seeded successfully.")) self.stdout.write(self.style.SUCCESS("com2care demonstration content seeded."))
def _content_exists(self):
return any(model.objects.exists() for model in self.CONTENT_MODELS)
@staticmethod
def _update_instance(instance, values):
for field, value in values.items():
setattr(instance, field, value)
instance.save()
@staticmethod
def _attach_static_image(instance, field_name, image_name):
if not image_name or getattr(instance, field_name):
return
source = settings.BASE_DIR / "static" / "images" / image_name
if not source.exists():
return
with source.open("rb") as handle:
getattr(instance, field_name).save(source.name, File(handle), save=True)
def _seed_products(self): def _seed_products(self):
SubProduct.objects.filter(show_on_homepage=False, homepage_order=0).update( seeded_main_product = None
show_on_homepage=True,
)
for product_data in MAIN_PRODUCTS: for product_data in MAIN_PRODUCTS:
sub_products_data = product_data.pop("sub_products") sub_products_data = product_data["sub_products"]
product_defaults = {
key: value for key, value in product_data.items() if key != "sub_products"
}
main_product, created = MainProduct.objects.get_or_create( main_product, created = MainProduct.objects.get_or_create(
slug=product_data["slug"], slug=product_defaults["slug"],
defaults=product_data, defaults=product_defaults,
) )
if not created: if not created:
for field, value in product_data.items(): self._update_instance(main_product, product_defaults)
setattr(main_product, field, value) seeded_main_product = main_product
main_product.save()
action = "Created" if created else "Updated" action = "Created" if created else "Updated"
self.stdout.write(f" {action} main product: {main_product.name}") self.stdout.write(f" {action} main product: {main_product.name}")
for sub_data in sub_products_data: for sub_data in sub_products_data:
articles_data = sub_data.pop("articles") articles_data = sub_data["articles"]
sub_defaults = {
key: value for key, value in sub_data.items() if key != "articles"
}
sub_product, sub_created = SubProduct.objects.get_or_create( sub_product, sub_created = SubProduct.objects.get_or_create(
main_product=main_product, main_product=main_product,
slug=sub_data["slug"], slug=sub_defaults["slug"],
defaults=sub_data, defaults=sub_defaults,
) )
if not sub_created: if not sub_created:
for field, value in sub_data.items(): self._update_instance(sub_product, sub_defaults)
setattr(sub_product, field, value)
sub_product.save()
sub_action = "Created" if sub_created else "Updated" sub_action = "Created" if sub_created else "Updated"
self.stdout.write(f" {sub_action} sub-product: {sub_product.name}") self.stdout.write(f" {sub_action} sub-product: {sub_product.name}")
for article_data in articles_data: for article_data in articles_data:
sections_data = article_data.pop("sections") sections_data = article_data["sections"]
article_defaults = {
key: value for key, value in article_data.items() if key != "sections"
}
article, art_created = Article.objects.get_or_create( article, art_created = Article.objects.get_or_create(
sub_product=sub_product, sub_product=sub_product,
title=article_data["title"], title=article_defaults["title"],
defaults=article_data, defaults=article_defaults,
) )
if not art_created: if not art_created:
for field, value in article_data.items(): self._update_instance(article, article_defaults)
setattr(article, field, value)
article.save()
art_action = "Created" if art_created else "Updated" art_action = "Created" if art_created else "Updated"
self.stdout.write(f" {art_action} article: {article.title}") self.stdout.write(f" {art_action} article: {article.title}")
for section_data in sections_data: for section_data in sections_data:
section, _ = ArticleSection.objects.get_or_create( article_section, section_created = ArticleSection.objects.get_or_create(
article=article, article=article,
title=section_data["title"], title=section_data["title"],
defaults=section_data, defaults=section_data,
) )
if not section_created:
self._update_instance(article_section, section_data)
return seeded_main_product
def _seed_faq(self): def _seed_faq(self):
for entry_data in FAQ_ENTRIES: for entry_data in FAQ_ENTRIES:
@@ -470,34 +624,92 @@ class Command(BaseCommand):
defaults=entry_data, defaults=entry_data,
) )
if not created: if not created:
for field, value in entry_data.items(): self._update_instance(faq, entry_data)
setattr(faq, field, value)
faq.save()
action = "Created" if created else "Updated" action = "Created" if created else "Updated"
self.stdout.write(f" {action} FAQ: {faq.question[:60]}...") self.stdout.write(f" {action} FAQ: {faq.question[:60]}...")
def _seed_homepage_sections(self): def _seed_homepage_sections(self):
for section_data in HOMEPAGE_SECTIONS: for section_data in HOMEPAGE_SECTIONS:
items_data = section_data.pop("items") items_data = section_data["items"]
section_defaults = {
key: value for key, value in section_data.items() if key != "items"
}
section, created = HomepageSection.objects.get_or_create( section, created = HomepageSection.objects.get_or_create(
section_type=section_data["section_type"], section_type=section_defaults["section_type"],
defaults=section_data, defaults=section_defaults,
) )
if not created: if not created:
for field, value in section_data.items(): self._update_instance(section, section_defaults)
setattr(section, field, value)
section.save()
action = "Created" if created else "Updated" action = "Created" if created else "Updated"
self.stdout.write(f" {action} homepage section: {section}") self.stdout.write(f" {action} homepage section: {section}")
for item_data in items_data: for item_data in items_data:
item, _ = HomepageSectionItem.objects.get_or_create( image_name = item_data.get("static_image", "")
item_defaults = {
key: value for key, value in item_data.items() if key != "static_image"
}
item, item_created = HomepageSectionItem.objects.get_or_create(
section=section, section=section,
title=item_data["title"], title=item_defaults["title"],
defaults=item_data, defaults=item_defaults,
) )
if not item_created:
self._update_instance(item, item_defaults)
self._attach_static_image(item, "image", image_name)
def _seed_about_sections(self):
for section_data in ABOUT_SECTIONS:
items_data = section_data["items"]
section_defaults = {
key: value for key, value in section_data.items() if key != "items"
}
section, created = AboutSection.objects.get_or_create(
section_type=section_defaults["section_type"],
title=section_defaults["title"],
defaults=section_defaults,
)
if not created:
self._update_instance(section, section_defaults)
for item_data in items_data:
image_name = item_data.get("static_image", "")
item_defaults = {
key: value for key, value in item_data.items() if key != "static_image"
}
item, item_created = AboutSectionItem.objects.get_or_create(
section=section,
title=item_defaults["title"],
defaults=item_defaults,
)
if not item_created:
self._update_instance(item, item_defaults)
self._attach_static_image(item, "image", image_name)
def _seed_product_videos(self, main_product):
if not main_product:
return
data = {
"badge": "Workflow Tutorial",
"title": "Segmentation workflow from image to 3D review",
"description": (
"An independent demonstration of a guided medical-image segmentation workflow "
"using open research tooling."
),
"video_url": "https://www.youtube.com/watch?v=_7oZygGp2ds",
"video_styled_background": True,
"video_size": "lg",
"order": 1,
"is_active": True,
}
video, created = ProductVideo.objects.get_or_create(
main_product=main_product,
title=data["title"],
defaults=data,
)
if not created:
self._update_instance(video, data)
def _seed_downloads(self): def _seed_downloads(self):
for item_data in DOWNLOAD_ITEMS: for item_data in DOWNLOAD_ITEMS:
@@ -507,73 +719,52 @@ class Command(BaseCommand):
defaults=item_data, defaults=item_data,
) )
if not created: if not created:
for field, value in item_data.items(): self._update_instance(item, item_data)
setattr(item, field, value)
item.save()
action = "Created" if created else "Updated" action = "Created" if created else "Updated"
self.stdout.write(f" {action} download: {item}") self.stdout.write(f" {action} download: {item}")
def _seed_hero(self): def _seed_hero(self):
data = { data = {
"badge": "Developing since 2021", "badge": "Communication-first medical imaging",
"title": "Radiuma,", "title": "Communication to Care,",
"title_highlight": "A Powerful Workflow Generator", "title_highlight": "From Images to Shared Understanding",
"subtitle": "for Standardized Radiomics Analysis and Medical Image Visualization", "subtitle": "Collaborative medical imaging, radiomics, and reproducible research workflows",
"description": ( "description": (
"Radiuma is a free, open-source software specialized for visualization, processing, " "com2care helps multidisciplinary teams explore complex imaging evidence, "
"segmentation, registration, fusion and analysis of medical and biomedical images, " "document analysis choices, and communicate results with clearer context."
"including radiomics and machine learning analysis."
), ),
"primary_cta_text": "Get Radiuma", "primary_cta_text": "Explore com2care",
"primary_cta_url": "/products/", "primary_cta_url": "/products/",
"secondary_cta_text": "About Radiuma", "secondary_cta_text": "Our Mission",
"secondary_cta_url": "/about/", "secondary_cta_url": "/about/",
"image_alt": "Radiuma application — main workflow view", "image_alt": "A multidisciplinary care team collaborating around medical imaging",
} }
hero = HeroSection.objects.first() hero = HeroSection.objects.first()
if hero is None: if hero is None:
HeroSection.objects.create(**data) hero = HeroSection.objects.create(**data)
self.stdout.write(" Created hero section")
else: else:
for field, value in data.items(): self._update_instance(hero, data)
if field == "image": self._attach_static_image(hero, "image", "com2care-care-team.jpg")
continue
setattr(hero, field, value) def _seed_site_branding(self):
hero.save() branding = SiteBranding.load()
self.stdout.write(" Updated hero section") branding.icon_alt = "com2care"
branding.hero_logo_alt = "Communication to Care"
branding.save(update_fields=["icon_alt", "hero_logo_alt"])
def _seed_site_contact(self): def _seed_site_contact(self):
contact, created = SiteContact.objects.get_or_create( data = {
pk=1, "support_email": "support@com2care.com",
defaults={ "discord_url": "",
"support_email": "support@radiuma.com", "discord_label": "",
"discord_url": "https://discord.gg/9XxA6pV9hb", "email_card_title": "Email com2care Support",
"email_card_description": "For direct software support:", "email_card_description": "For product, evaluation, and research questions:",
"discord_card_description": "Join for community support and announcements.", "discord_card_title": "",
"office_address": ( "discord_card_description": "",
"BC Cancer Research Center\n" "office_card_title": "",
"675 West 10th Ave, Office 6-112\n" "office_address": "",
"Vancouver, BC, V5Z 1L3\n"
"Canada"
),
},
)
if not created:
updates = {
"support_email": "support@radiuma.com",
"discord_url": "https://discord.gg/9XxA6pV9hb",
"email_card_description": "For direct software support:",
"discord_card_description": "Join for community support and announcements.",
"office_address": (
"BC Cancer Research Center\n"
"675 West 10th Ave, Office 6-112\n"
"Vancouver, BC, V5Z 1L3\n"
"Canada"
),
} }
for field, value in updates.items(): contact, created = SiteContact.objects.get_or_create(pk=1, defaults=data)
setattr(contact, field, value) if not created:
contact.save() self._update_instance(contact, data)
action = "Created" if created else "Updated"
self.stdout.write(f" {action} site contact")
@@ -0,0 +1,44 @@
# Generated by Django 5.2.13 on 2026-08-01 14:10
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_site_contact'),
]
operations = [
migrations.AddField(
model_name='sitebranding',
name='hero_logo',
field=models.ImageField(blank=True, help_text='Large brand artwork shown in the homepage hero. Leave empty to use the existing hero image.', null=True, upload_to='branding/hero/'),
),
migrations.AddField(
model_name='sitebranding',
name='hero_logo_alt',
field=models.CharField(blank=True, default='Radiuma', help_text='Accessible description for the homepage hero logo.', max_length=200),
),
migrations.AddField(
model_name='sitebranding',
name='website_icon',
field=models.FileField(blank=True, help_text='Icon shown in browser tabs and bookmarks. Use a square ICO, PNG, SVG, JPG, or WebP file. Leave empty to use the default Radiuma icons.', null=True, upload_to='branding/icons/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=('ico', 'png', 'svg', 'jpg', 'jpeg', 'webp'))]),
),
migrations.AlterField(
model_name='sitebranding',
name='footer_icon_size',
field=models.PositiveSmallIntegerField(default=34, help_text='Maximum height in pixels for the footer logo. Width scales automatically.'),
),
migrations.AlterField(
model_name='sitebranding',
name='icon',
field=models.ImageField(blank=True, help_text='Brand logo shown in the site navigation and footer. Leave empty to use the default logo.', null=True, upload_to='branding/', verbose_name='Header and footer logo'),
),
migrations.AlterField(
model_name='sitebranding',
name='navbar_icon_size',
field=models.PositiveSmallIntegerField(default=26, help_text='Maximum height in pixels for the header logo. Width scales automatically.'),
),
]
@@ -0,0 +1,24 @@
# Generated by Django 5.2.8 on 2026-08-01 15:06
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0003_sitebranding_hero_logo_sitebranding_hero_logo_alt_and_more'),
]
operations = [
migrations.AlterField(
model_name='sitebranding',
name='hero_logo_alt',
field=models.CharField(blank=True, default='Communication to Care', help_text='Accessible description for the homepage hero logo.', max_length=200),
),
migrations.AlterField(
model_name='sitebranding',
name='website_icon',
field=models.FileField(blank=True, help_text='Icon shown in browser tabs and bookmarks. Use a square ICO, PNG, SVG, JPG, or WebP file. Leave empty to use the default Communication to Care icons.', null=True, upload_to='branding/icons/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=('ico', 'png', 'svg', 'jpg', 'jpeg', 'webp'))]),
),
]
+36 -5
View File
@@ -1,3 +1,4 @@
from django.core.validators import FileExtensionValidator
from django.db import models from django.db import models
@@ -13,20 +14,50 @@ class SiteBranding(models.Model):
upload_to="branding/", upload_to="branding/",
blank=True, blank=True,
null=True, null=True,
help_text="Logo shown in the site header and footer. Leave empty to use the default static icon.", verbose_name="Header and footer logo",
help_text="Brand logo shown in the site navigation and footer. Leave empty to use the default logo.",
) )
icon_alt = models.CharField( icon_alt = models.CharField(
max_length=200, max_length=200,
blank=True, blank=True,
help_text="Alt text for the brand icon (decorative icons can stay empty).", help_text="Alt text for the brand icon (decorative icons can stay empty).",
) )
website_icon = models.FileField(
upload_to="branding/icons/",
blank=True,
null=True,
validators=[
FileExtensionValidator(
allowed_extensions=("ico", "png", "svg", "jpg", "jpeg", "webp")
)
],
help_text=(
"Icon shown in browser tabs and bookmarks. Use a square ICO, PNG, SVG, "
"JPG, or WebP file. Leave empty to use the default Communication to Care icons."
),
)
hero_logo = models.ImageField(
upload_to="branding/hero/",
blank=True,
null=True,
help_text=(
"Large brand artwork shown in the homepage hero. Leave empty to use "
"the existing hero image."
),
)
hero_logo_alt = models.CharField(
max_length=200,
blank=True,
default="Communication to Care",
help_text="Accessible description for the homepage hero logo.",
)
navbar_icon_size = models.PositiveSmallIntegerField( navbar_icon_size = models.PositiveSmallIntegerField(
default=26, default=26,
help_text="Width and height in pixels for the header icon.", help_text="Maximum height in pixels for the header logo. Width scales automatically.",
) )
footer_icon_size = models.PositiveSmallIntegerField( footer_icon_size = models.PositiveSmallIntegerField(
default=34, default=34,
help_text="Width and height in pixels for the footer icon.", help_text="Maximum height in pixels for the footer logo. Width scales automatically.",
) )
show_border = models.BooleanField( show_border = models.BooleanField(
default=False, default=False,
@@ -61,9 +92,9 @@ class SiteBranding(models.Model):
def icon_style(self, size_px): def icon_style(self, size_px):
parts = [ parts = [
f"width:{size_px}px", "width:auto",
f"height:{size_px}px", f"height:{size_px}px",
f"object-fit:{self.object_fit}", "object-fit:contain",
] ]
if self.show_border: if self.show_border:
parts.append(f"border:{self.border_width}px solid {self.border_color}") parts.append(f"border:{self.border_width}px solid {self.border_color}")
+120
View File
@@ -0,0 +1,120 @@
import os
from tempfile import TemporaryDirectory
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.test import TestCase
from apps.core.models import SiteContact
from apps.pages.models import AboutSection, HeroSection, HomepageSection
from apps.products.models import MainProduct, ProductVideo
class DemoSeedCommandTests(TestCase):
def setUp(self):
self.media_directory = TemporaryDirectory()
self.addCleanup(self.media_directory.cleanup)
def _seed_if_empty(self):
with self.settings(MEDIA_ROOT=self.media_directory.name):
call_command("seed_content", "--if-empty", verbosity=0)
def test_seed_populates_complete_demo_when_public_content_is_empty(self):
self._seed_if_empty()
self.assertTrue(MainProduct.objects.filter(slug="com2care").exists())
self.assertTrue(HeroSection.objects.exclude(image="").exists())
self.assertTrue(HomepageSection.objects.filter(section_type="video").exists())
self.assertTrue(AboutSection.objects.filter(section_type="video").exists())
self.assertTrue(ProductVideo.objects.filter(video_url__contains="youtube.com").exists())
self.assertEqual(SiteContact.load().support_email, "support@com2care.com")
def test_if_empty_preserves_existing_admin_content(self):
self._seed_if_empty()
hero = HeroSection.objects.get()
hero.title = "Admin-authored headline"
hero.save(update_fields=["title"])
self._seed_if_empty()
hero.refresh_from_db()
self.assertEqual(hero.title, "Admin-authored headline")
class EnsureSuperuserCommandTests(TestCase):
def test_creates_configurable_first_admin(self):
environment = {
"DJANGO_SUPERUSER_ENABLED": "True",
"DJANGO_SUPERUSER_USERNAME": "siteadmin",
"DJANGO_SUPERUSER_EMAIL": "siteadmin@com2care.com",
"DJANGO_SUPERUSER_PASSWORD": "configurable-test-password",
"DJANGO_SUPERUSER_SYNC_PASSWORD": "False",
}
with patch.dict(os.environ, environment, clear=False):
call_command("ensure_superuser", verbosity=0)
user = get_user_model().objects.get(username="siteadmin")
self.assertTrue(user.is_superuser)
self.assertTrue(user.check_password("configurable-test-password"))
def test_restart_preserves_password_changed_in_admin(self):
user = get_user_model().objects.create_superuser(
username="admin",
email="admin@com2care.com",
password="changed-in-admin",
)
environment = {
"DJANGO_SUPERUSER_USERNAME": "admin",
"DJANGO_SUPERUSER_EMAIL": "admin@com2care.com",
"DJANGO_SUPERUSER_PASSWORD": "environment-password",
"DJANGO_SUPERUSER_SYNC_PASSWORD": "False",
}
with patch.dict(os.environ, environment, clear=False):
call_command("ensure_superuser", verbosity=0)
user.refresh_from_db()
self.assertTrue(user.check_password("changed-in-admin"))
def test_server_operator_can_explicitly_rotate_password(self):
user = get_user_model().objects.create_superuser(
username="admin",
email="old@com2care.com",
password="old-password",
)
environment = {
"DJANGO_SUPERUSER_USERNAME": "admin",
"DJANGO_SUPERUSER_EMAIL": "new@com2care.com",
"DJANGO_SUPERUSER_PASSWORD": "rotated-password",
"DJANGO_SUPERUSER_SYNC_PASSWORD": "True",
}
with patch.dict(os.environ, environment, clear=False):
call_command("ensure_superuser", verbosity=0)
user.refresh_from_db()
self.assertEqual(user.email, "new@com2care.com")
self.assertTrue(user.check_password("rotated-password"))
class NormalizeBrandCommandTests(TestCase):
def test_normalizes_existing_public_content_urls_and_slugs(self):
old_name = "".join(("Radi", "uma"))
old_slug = old_name.lower()
product = MainProduct.objects.create(
name=old_name,
slug=old_slug,
short_description=f"A workflow from {old_name}",
description=f"Learn more at {old_slug}.com.",
)
contact = SiteContact.load()
contact.support_email = f"support@{old_slug}.com"
contact.save(update_fields=["support_email"])
call_command("normalize_brand", verbosity=0)
product.refresh_from_db()
contact.refresh_from_db()
self.assertEqual(product.name, "Communication to Care")
self.assertEqual(product.slug, "com2care")
self.assertEqual(product.description, "Learn more at com2care.com.")
self.assertEqual(contact.support_email, "support@com2care.com")
+27 -1
View File
@@ -1,4 +1,5 @@
from django.test import RequestFactory, TestCase from django.test import RequestFactory, TestCase
from django.urls import reverse
from apps.core.context_processors import site_branding from apps.core.context_processors import site_branding
from apps.core.models import SiteBranding from apps.core.models import SiteBranding
@@ -17,7 +18,9 @@ class SiteBrandingModelTests(TestCase):
branding.border_width = 2 branding.border_width = 2
branding.navbar_icon_size = 30 branding.navbar_icon_size = 30
style = branding.navbar_icon_style style = branding.navbar_icon_style
self.assertIn("width:30px", style) self.assertIn("width:auto", style)
self.assertIn("height:30px", style)
self.assertIn("object-fit:contain", style)
self.assertIn("border:2px solid #ffffff", style) self.assertIn("border:2px solid #ffffff", style)
def test_icon_style_omits_border_when_disabled(self): def test_icon_style_omits_border_when_disabled(self):
@@ -33,3 +36,26 @@ class SiteBrandingContextProcessorTests(TestCase):
ctx = site_branding(request) ctx = site_branding(request)
self.assertIn("site_branding", ctx) self.assertIn("site_branding", ctx)
self.assertIsInstance(ctx["site_branding"], SiteBranding) self.assertIsInstance(ctx["site_branding"], SiteBranding)
class SiteBrandingTemplateTests(TestCase):
def test_custom_brand_assets_are_rendered_independently(self):
branding = SiteBranding.load()
branding.icon = "branding/navigation-logo.png"
branding.website_icon = "branding/icons/site-icon.png"
branding.hero_logo = "branding/hero/hero-logo.png"
branding.hero_logo_alt = "Communication to Care research platform"
branding.save()
response = self.client.get(reverse("pages:home"))
self.assertContains(response, 'src="/media/branding/navigation-logo.png"')
self.assertContains(response, 'href="/media/branding/icons/site-icon.png"')
self.assertContains(response, 'src="/media/branding/hero/hero-logo.png"')
self.assertContains(response, 'alt="Communication to Care research platform"')
def test_default_assets_remain_when_custom_assets_are_empty(self):
response = self.client.get(reverse("pages:home"))
self.assertContains(response, "images/com2care-mark.svg")
self.assertContains(response, "images/com2care-care-team.jpg")
+172
View File
@@ -0,0 +1,172 @@
import re
from django.core.exceptions import ValidationError
from django.db import models
from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_PLAIN, render_content
VIDEO_SOURCE_YOUTUBE = "youtube"
VIDEO_SOURCE_UPLOAD = "upload"
VIDEO_SOURCE_CHOICES = [
(VIDEO_SOURCE_YOUTUBE, "YouTube / external link"),
(VIDEO_SOURCE_UPLOAD, "Uploaded file"),
]
VIDEO_SIZE_SMALL = "sm"
VIDEO_SIZE_MEDIUM = "md"
VIDEO_SIZE_LARGE = "lg"
VIDEO_SIZE_FULL = "full"
VIDEO_SIZE_CHOICES = [
(VIDEO_SIZE_SMALL, "Small (480px)"),
(VIDEO_SIZE_MEDIUM, "Medium (720px)"),
(VIDEO_SIZE_LARGE, "Large (960px)"),
(VIDEO_SIZE_FULL, "Full width"),
]
VIDEO_ASPECT_16_9 = "16/9"
VIDEO_ASPECT_4_3 = "4/3"
VIDEO_ASPECT_1_1 = "1/1"
VIDEO_ASPECT_CHOICES = [
(VIDEO_ASPECT_16_9, "16:9 (widescreen)"),
(VIDEO_ASPECT_4_3, "4:3 (standard)"),
(VIDEO_ASPECT_1_1, "1:1 (square)"),
]
VIDEO_UPLOAD_EXTENSIONS = frozenset({".mp4", ".webm", ".ogg", ".mov"})
VIDEO_ADMIN_FIELDS = (
"video_source",
"video_url",
"video_file",
"video_poster",
"video_size",
"video_aspect_ratio",
"video_styled_background",
)
VIDEO_ADMIN_FIELDSET = (
"Video",
{
"fields": VIDEO_ADMIN_FIELDS + ("video_preview",),
"description": (
"Choose YouTube link or uploaded file. Set display size and aspect ratio "
"to control how the preview appears on the site."
),
},
)
YOUTUBE_ID_PATTERNS = (
re.compile(r"(?:youtube\.com/watch\?(?:[^&]+&)*v=|youtube\.com/embed/|youtube\.com/shorts/|youtu\.be/)([\w-]{11})"),
re.compile(r"^([\w-]{11})$"),
)
def parse_youtube_video_id(url):
if not url:
return ""
value = url.strip()
for pattern in YOUTUBE_ID_PATTERNS:
match = pattern.search(value)
if match:
return match.group(1)
return ""
def youtube_embed_url(url):
video_id = parse_youtube_video_id(url)
if not video_id:
return ""
return f"https://www.youtube-nocookie.com/embed/{video_id}"
class VideoBlockMixin(models.Model):
video_source = models.CharField(
max_length=20,
choices=VIDEO_SOURCE_CHOICES,
default=VIDEO_SOURCE_YOUTUBE,
blank=True,
)
video_url = models.CharField(
max_length=500,
blank=True,
help_text="YouTube watch, embed, or youtu.be link.",
)
video_file = models.FileField(
upload_to="videos/",
blank=True,
help_text="MP4, WebM, OGG, or MOV file.",
)
video_poster = models.ImageField(
upload_to="videos/posters/",
blank=True,
null=True,
help_text="Optional thumbnail shown before an uploaded video plays.",
)
video_size = models.CharField(
max_length=10,
choices=VIDEO_SIZE_CHOICES,
default=VIDEO_SIZE_MEDIUM,
)
video_aspect_ratio = models.CharField(
max_length=10,
choices=VIDEO_ASPECT_CHOICES,
default=VIDEO_ASPECT_16_9,
)
video_styled_background = models.BooleanField(
default=False,
help_text="Glass panel with ambient glow (similar to the Downloads section).",
)
description_format = models.CharField(
max_length=20,
choices=CONTENT_FORMAT_CHOICES,
default=FORMAT_PLAIN,
)
class Meta:
abstract = True
@property
def youtube_embed_url(self):
return youtube_embed_url(self.video_url)
@property
def has_video(self):
if self.video_source == VIDEO_SOURCE_UPLOAD:
return bool(self.video_file)
return bool(self.youtube_embed_url)
@property
def video_size_class(self):
return f"video-block--{self.video_size or VIDEO_SIZE_MEDIUM}"
@property
def video_aspect_class(self):
ratio = (self.video_aspect_ratio or VIDEO_ASPECT_16_9).replace("/", "-")
return f"video-block--ratio-{ratio}"
@property
def rendered_description(self):
description = getattr(self, "description", "") or ""
return render_content(description, self.description_format)
def clean_video_fields(self, require=False):
if not require and not self.video_url and not self.video_file:
return
if self.video_source == VIDEO_SOURCE_YOUTUBE:
if not self.video_url.strip():
raise ValidationError({"video_url": "Enter a YouTube link."})
if not self.youtube_embed_url:
raise ValidationError({"video_url": "Enter a valid YouTube link."})
elif self.video_source == VIDEO_SOURCE_UPLOAD:
if not self.video_file:
raise ValidationError({"video_file": "Upload a video file."})
extension = self.video_file.name.rsplit(".", 1)[-1].lower() if self.video_file.name else ""
if f".{extension}" not in VIDEO_UPLOAD_EXTENSIONS:
raise ValidationError(
{
"video_file": "Unsupported format. Use MP4, WebM, OGG, or MOV.",
}
)
+209 -7
View File
@@ -1,16 +1,25 @@
from django.contrib import admin from django.contrib import admin
from django.http import HttpResponseRedirect from django.http import FileResponse, Http404, HttpResponseRedirect
from django.urls import reverse from django.urls import path, reverse
from django.utils.html import format_html
from apps.core.admin_video import video_admin_preview
from apps.core.video import VIDEO_ADMIN_FIELDSET
from .models import ( from .models import (
AboutSection, AboutSection,
AboutSectionItem, AboutSectionItem,
ContactSubmission, ContactSubmission,
ContactSubmissionAttachment,
CustomPage,
CustomPageSection,
CustomPageSectionItem,
DownloadItem, DownloadItem,
FAQEntry, FAQEntry,
HeroSection, HeroSection,
HomepageSection, HomepageSection,
HomepageSectionItem, HomepageSectionItem,
PageVideo,
) )
@@ -43,27 +52,33 @@ class HeroSectionAdmin(admin.ModelAdmin):
class HomepageSectionItemInline(admin.TabularInline): class HomepageSectionItemInline(admin.TabularInline):
model = HomepageSectionItem model = HomepageSectionItem
extra = 1 extra = 1
fields = ("icon", "title", "content", "image", "order") fields = ("icon", "title", "content", "url", "image", "order")
ordering = ("order",) ordering = ("order",)
@admin.register(HomepageSection) @admin.register(HomepageSection)
class HomepageSectionAdmin(admin.ModelAdmin): class HomepageSectionAdmin(admin.ModelAdmin):
list_display = ("section_type", "badge", "title", "order", "is_active") list_display = ("section_type", "title", "badge", "order", "is_active")
list_filter = ("section_type", "is_active") list_filter = ("section_type", "is_active")
list_editable = ("order", "is_active") list_editable = ("order", "is_active")
inlines = [HomepageSectionItemInline] inlines = [HomepageSectionItemInline]
readonly_fields = ("video_preview",)
fieldsets = ( fieldsets = (
(None, {"fields": ("section_type", "badge", "title", "description")}), (None, {"fields": ("section_type", "badge", "title", "description_format", "description")}),
("CTA Link (About Strip)", {"fields": ("link_text", "link_url"), "classes": ("collapse",)}), ("CTA Link (About Strip)", {"fields": ("link_text", "link_url"), "classes": ("collapse",)}),
VIDEO_ADMIN_FIELDSET,
("Settings", {"fields": ("order", "is_active")}), ("Settings", {"fields": ("order", "is_active")}),
) )
@admin.display(description="Preview")
def video_preview(self, obj):
return video_admin_preview(obj)
class AboutSectionItemInline(admin.StackedInline):
class AboutSectionItemInline(admin.TabularInline):
model = AboutSectionItem model = AboutSectionItem
extra = 1 extra = 1
fields = ("badge", "title", "content", "url", "image", "image_alt", "is_featured", "order") fields = ("icon", "title", "content", "url", "image", "image_alt", "badge", "is_featured", "order")
ordering = ("order",) ordering = ("order",)
@@ -73,12 +88,33 @@ class AboutSectionAdmin(admin.ModelAdmin):
list_filter = ("section_type", "is_active") list_filter = ("section_type", "is_active")
list_editable = ("order", "is_active") list_editable = ("order", "is_active")
inlines = [AboutSectionItemInline] inlines = [AboutSectionItemInline]
readonly_fields = ("video_preview",)
fieldsets = ( fieldsets = (
(None, {"fields": ("section_type", "badge", "title", "subtitle")}), (None, {"fields": ("section_type", "badge", "title", "subtitle")}),
("Content", {"fields": ("content_format", "content")}), ("Content", {"fields": ("content_format", "content")}),
VIDEO_ADMIN_FIELDSET,
("Settings", {"fields": ("order", "is_active")}), ("Settings", {"fields": ("order", "is_active")}),
) )
@admin.display(description="Preview")
def video_preview(self, obj):
return video_admin_preview(obj)
class ContactSubmissionAttachmentInline(admin.TabularInline):
model = ContactSubmissionAttachment
extra = 0
can_delete = False
fields = ("original_filename", "attachment_link", "uploaded_at")
readonly_fields = ("original_filename", "attachment_link", "uploaded_at")
@admin.display(description="File")
def attachment_link(self, obj):
if not obj.file:
return ""
url = reverse("admin:pages_contactattachment_download", args=[obj.pk])
return format_html('<a href="{}">Download</a>', url)
@admin.register(ContactSubmission) @admin.register(ContactSubmission)
class ContactSubmissionAdmin(admin.ModelAdmin): class ContactSubmissionAdmin(admin.ModelAdmin):
@@ -87,11 +123,36 @@ class ContactSubmissionAdmin(admin.ModelAdmin):
search_fields = ("name", "title", "description", "email") search_fields = ("name", "title", "description", "email")
list_editable = ("is_read",) list_editable = ("is_read",)
readonly_fields = ("name", "title", "description", "email", "submitted_at") readonly_fields = ("name", "title", "description", "email", "submitted_at")
inlines = [ContactSubmissionAttachmentInline]
fieldsets = ( fieldsets = (
(None, {"fields": ("name", "title", "email", "description")}), (None, {"fields": ("name", "title", "email", "description")}),
("Meta", {"fields": ("submitted_at", "is_read")}), ("Meta", {"fields": ("submitted_at", "is_read")}),
) )
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path(
"attachment/<int:attachment_id>/download/",
self.admin_site.admin_view(self.download_attachment),
name="pages_contactattachment_download",
),
]
return custom_urls + urls
def download_attachment(self, request, attachment_id):
try:
attachment = ContactSubmissionAttachment.objects.get(pk=attachment_id)
except ContactSubmissionAttachment.DoesNotExist as exc:
raise Http404 from exc
if not attachment.file:
raise Http404
return FileResponse(
attachment.file.open("rb"),
as_attachment=True,
filename=attachment.original_filename,
)
@admin.register(FAQEntry) @admin.register(FAQEntry)
class FAQEntryAdmin(admin.ModelAdmin): class FAQEntryAdmin(admin.ModelAdmin):
@@ -106,6 +167,147 @@ class FAQEntryAdmin(admin.ModelAdmin):
) )
class CustomPageSectionItemInline(admin.TabularInline):
model = CustomPageSectionItem
extra = 1
fields = (
"icon",
"badge",
"title",
"content_format",
"content",
"url",
"image",
"image_alt",
"is_featured",
"order",
)
ordering = ("order",)
class CustomPageSectionInline(admin.StackedInline):
model = CustomPageSection
extra = 1
fields = (
"section_type",
"badge",
"title",
"subtitle",
"description_format",
"description",
"content_format",
"content",
"link_text",
"link_url",
"order",
"is_active",
)
ordering = ("order",)
show_change_link = True
class CustomPageSectionItemOnPageInline(admin.TabularInline):
model = CustomPageSectionItem
fk_name = "page"
extra = 1
verbose_name = "Section item"
verbose_name_plural = "Section items (subsections)"
fields = (
"section",
"icon",
"badge",
"title",
"content_format",
"content",
"url",
"image",
"order",
)
ordering = ("section", "order")
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "section":
page_id = request.resolver_match.kwargs.get("object_id") if request.resolver_match else None
if page_id:
kwargs["queryset"] = CustomPageSection.objects.filter(page_id=page_id).order_by("order")
return super().formfield_for_foreignkey(db_field, request, **kwargs)
class CustomPageSectionAdmin(admin.ModelAdmin):
list_display = ("page", "section_type", "title", "order", "is_active")
list_filter = ("section_type", "is_active", "page")
list_editable = ("order", "is_active")
inlines = [CustomPageSectionItemInline]
readonly_fields = ("video_preview",)
fieldsets = (
(None, {"fields": ("page", "section_type", "badge", "title", "subtitle")}),
("Text", {"fields": ("description_format", "description", "content_format", "content")}),
("CTA Link", {"fields": ("link_text", "link_url"), "classes": ("collapse",)}),
VIDEO_ADMIN_FIELDSET,
("Settings", {"fields": ("order", "is_active")}),
)
@admin.display(description="Preview")
def video_preview(self, obj):
return video_admin_preview(obj)
def save_formset(self, request, form, formset, change):
instances = formset.save(commit=False)
for instance in instances:
if isinstance(instance, CustomPageSectionItem):
instance.page = form.instance.page
instance.save()
for obj in formset.deleted_objects:
obj.delete()
formset.save_m2m()
@admin.register(CustomPage)
class CustomPageAdmin(admin.ModelAdmin):
list_display = ("title", "slug", "show_in_nav", "menu_order", "is_published", "updated_at")
list_filter = ("show_in_nav", "is_published")
search_fields = ("title", "slug", "menu_label")
list_editable = ("show_in_nav", "menu_order", "is_published")
prepopulated_fields = {"slug": ("title",)}
inlines = [CustomPageSectionInline, CustomPageSectionItemOnPageInline]
fieldsets = (
(None, {"fields": ("title", "slug", "menu_label", "meta_description")}),
("Navigation", {"fields": ("show_in_nav", "menu_order")}),
("Publishing", {"fields": ("is_published",)}),
)
def save_formset(self, request, form, formset, change):
instances = formset.save(commit=False)
for instance in instances:
if isinstance(instance, CustomPageSectionItem):
instance.page = form.instance
instance.save()
for obj in formset.deleted_objects:
obj.delete()
formset.save_m2m()
admin.site.register(CustomPageSection, CustomPageSectionAdmin)
@admin.register(PageVideo)
class PageVideoAdmin(admin.ModelAdmin):
list_display = ("page", "title", "video_source", "video_size", "order", "is_active")
list_filter = ("page", "video_source", "is_active")
list_editable = ("order", "is_active")
search_fields = ("title", "description", "video_url")
readonly_fields = ("video_preview",)
fieldsets = (
(None, {"fields": ("page", "badge", "title", "description_format", "description")}),
VIDEO_ADMIN_FIELDSET,
("Settings", {"fields": ("order", "is_active")}),
)
@admin.display(description="Preview")
def video_preview(self, obj):
return video_admin_preview(obj)
@admin.register(DownloadItem) @admin.register(DownloadItem)
class DownloadItemAdmin(admin.ModelAdmin): class DownloadItemAdmin(admin.ModelAdmin):
list_display = ("name", "platform", "version", "is_active", "order") list_display = ("name", "platform", "version", "is_active", "order")
+188
View File
@@ -0,0 +1,188 @@
import os
import uuid
import zipfile
from pathlib import PurePosixPath
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.files.storage import FileSystemStorage
from django.core.files.uploadedfile import UploadedFile
from django.utils.text import get_valid_filename
from PIL import Image
ALLOWED_EXTENSIONS = frozenset(
{
".png",
".jpg",
".jpeg",
".gif",
".webp",
".bmp",
".zip",
".log",
".txt",
}
)
IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"})
TEXT_EXTENSIONS = frozenset({".log", ".txt"})
ARCHIVE_EXTENSIONS = frozenset({".zip"})
MAX_FILE_SIZE = getattr(settings, "CONTACT_ATTACHMENT_MAX_SIZE", 10 * 1024 * 1024)
MAX_ATTACHMENTS = getattr(settings, "CONTACT_ATTACHMENT_MAX_COUNT", 3)
MAX_ZIP_ENTRIES = 100
MAX_ZIP_UNCOMPRESSED_SIZE = 50 * 1024 * 1024
class ContactAttachmentStorage(FileSystemStorage):
def __init__(self):
super().__init__(location=settings.CONTACT_UPLOAD_ROOT)
contact_attachment_storage = ContactAttachmentStorage()
def contact_attachment_upload_to(instance, _filename):
ext = os.path.splitext(instance.original_filename)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
ext = ".bin"
subdir = str(instance.submission_id) if instance.submission_id else "pending"
return f"{subdir}/{uuid.uuid4().hex}{ext}"
def _extension(filename):
return os.path.splitext(filename)[1].lower()
def _read_header(uploaded_file, size=32):
uploaded_file.seek(0)
header = uploaded_file.read(size)
uploaded_file.seek(0)
return header
def _validate_magic(header, ext):
if ext == ".png" and not header.startswith(b"\x89PNG\r\n\x1a\n"):
raise ValidationError("File content does not match its type.")
if ext in (".jpg", ".jpeg") and not header.startswith(b"\xff\xd8\xff"):
raise ValidationError("File content does not match its type.")
if ext == ".gif" and not (
header.startswith(b"GIF87a") or header.startswith(b"GIF89a")
):
raise ValidationError("File content does not match its type.")
if ext == ".webp" and not (
len(header) >= 12 and header[0:4] == b"RIFF" and header[8:12] == b"WEBP"
):
raise ValidationError("File content does not match its type.")
if ext == ".bmp" and not header.startswith(b"BM"):
raise ValidationError("File content does not match its type.")
if ext == ".zip" and not (
header.startswith(b"PK\x03\x04")
or header.startswith(b"PK\x05\x06")
or header.startswith(b"PK\x07\x08")
):
raise ValidationError("File content does not match its type.")
def _validate_image(uploaded_file):
try:
uploaded_file.seek(0)
with Image.open(uploaded_file) as img:
img.verify()
uploaded_file.seek(0)
with Image.open(uploaded_file) as img:
img.load()
except Exception as exc:
raise ValidationError("Invalid or corrupted image file.") from exc
finally:
uploaded_file.seek(0)
def _validate_text(uploaded_file):
uploaded_file.seek(0)
data = uploaded_file.read()
uploaded_file.seek(0)
if b"\x00" in data:
raise ValidationError("Text files must not contain binary data.")
try:
data.decode("utf-8")
except UnicodeDecodeError:
try:
data.decode("latin-1")
except UnicodeDecodeError as exc:
raise ValidationError("Text file is not valid UTF-8 or Latin-1.") from exc
def _validate_zip(uploaded_file):
uploaded_file.seek(0)
if not zipfile.is_zipfile(uploaded_file):
raise ValidationError("Invalid ZIP archive.")
uploaded_file.seek(0)
total_uncompressed = 0
entry_count = 0
with zipfile.ZipFile(uploaded_file, "r") as archive:
for info in archive.infolist():
entry_count += 1
if entry_count > MAX_ZIP_ENTRIES:
raise ValidationError("ZIP archive contains too many files.")
name = info.filename
if name.startswith("/") or ".." in PurePosixPath(name).parts:
raise ValidationError("ZIP archive contains unsafe paths.")
if info.flag_bits & 0x1:
raise ValidationError("Encrypted ZIP archives are not allowed.")
total_uncompressed += info.file_size
if total_uncompressed > MAX_ZIP_UNCOMPRESSED_SIZE:
raise ValidationError("ZIP archive uncompressed size is too large.")
uploaded_file.seek(0)
def validate_contact_attachment(uploaded_file):
if uploaded_file.size > MAX_FILE_SIZE:
max_mb = MAX_FILE_SIZE // (1024 * 1024)
raise ValidationError(f"File exceeds the maximum size of {max_mb} MB.")
name = get_valid_filename(os.path.basename(uploaded_file.name))
if not name:
raise ValidationError("Invalid file name.")
ext = _extension(name)
if ext not in ALLOWED_EXTENSIONS:
raise ValidationError(
"File type not allowed. Permitted: images (PNG, JPG, GIF, WebP, BMP), "
"ZIP, LOG, or TXT."
)
header = _read_header(uploaded_file)
if ext in IMAGE_EXTENSIONS or ext in ARCHIVE_EXTENSIONS:
_validate_magic(header, ext)
if ext in IMAGE_EXTENSIONS:
_validate_image(uploaded_file)
elif ext in TEXT_EXTENSIONS:
_validate_text(uploaded_file)
elif ext in ARCHIVE_EXTENSIONS:
_validate_zip(uploaded_file)
return name
def validate_contact_attachments(files):
if not files:
return []
if len(files) > MAX_ATTACHMENTS:
raise ValidationError(f"You can attach at most {MAX_ATTACHMENTS} files.")
validated = []
for uploaded_file in files:
if not isinstance(uploaded_file, UploadedFile):
raise ValidationError("Invalid upload.")
original_name = validate_contact_attachment(uploaded_file)
validated.append((uploaded_file, original_name))
return validated
+17
View File
@@ -1,9 +1,13 @@
from django import forms from django import forms
from django.core.exceptions import ValidationError
from .contact_uploads import validate_contact_attachments
from .models import ContactSubmission from .models import ContactSubmission
class ContactForm(forms.ModelForm): class ContactForm(forms.ModelForm):
attachments = forms.Field(required=False)
class Meta: class Meta:
model = ContactSubmission model = ContactSubmission
fields = ["name", "title", "description", "email"] fields = ["name", "title", "description", "email"]
@@ -22,3 +26,16 @@ class ContactForm(forms.ModelForm):
} }
), ),
} }
def __init__(self, *args, file_list=None, **kwargs):
self.file_list = file_list
super().__init__(*args, **kwargs)
def clean(self):
cleaned_data = super().clean()
try:
cleaned_data["attachments"] = validate_contact_attachments(self.file_list)
except ValidationError as exc:
self.add_error("attachments", exc)
cleaned_data["attachments"] = []
return cleaned_data
@@ -0,0 +1,79 @@
# Generated by Django 5.0.2 on 2026-05-25 12:55
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0007_hero_section'),
]
operations = [
migrations.CreateModel(
name='CustomPage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('slug', models.SlugField(max_length=200, unique=True)),
('menu_label', models.CharField(blank=True, help_text='Nav label when shown in menu. Defaults to title.', max_length=100)),
('meta_description', models.CharField(blank=True, max_length=300)),
('show_in_nav', models.BooleanField(default=True)),
('menu_order', models.PositiveIntegerField(default=0)),
('is_published', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Custom Page',
'verbose_name_plural': 'Custom Pages',
'ordering': ['menu_order', 'title'],
},
),
migrations.CreateModel(
name='CustomPageSection',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('section_type', models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('features', 'Features Grid'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products Grid'), ('problems', 'Problems / Value Proposition'), ('supporters', 'Supporters'), ('about_strip', 'About Strip'), ('faq', 'FAQ Accordion')], default='custom', max_length=30)),
('badge', models.CharField(blank=True, max_length=100)),
('title', models.CharField(blank=True, max_length=300)),
('subtitle', models.CharField(blank=True, max_length=500)),
('description', models.TextField(blank=True, help_text='Short intro text (homepage-style sections).')),
('content', models.TextField(blank=True)),
('content_format', models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='markdown', max_length=20)),
('link_text', models.CharField(blank=True, max_length=100)),
('link_url', models.CharField(blank=True, max_length=300)),
('order', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='pages.custompage')),
],
options={
'verbose_name': 'Custom Page Section',
'verbose_name_plural': 'Custom Page Sections',
'ordering': ['order'],
},
),
migrations.CreateModel(
name='CustomPageSectionItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('icon', models.CharField(blank=True, max_length=20)),
('badge', models.CharField(blank=True, max_length=100)),
('title', models.CharField(blank=True, max_length=300)),
('content', models.TextField(blank=True)),
('content_format', models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20)),
('url', models.URLField(blank=True)),
('image', models.ImageField(blank=True, null=True, upload_to='pages/custom/')),
('image_alt', models.CharField(blank=True, max_length=200)),
('is_featured', models.BooleanField(default=False)),
('order', models.PositiveIntegerField(default=0)),
('section', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='pages.custompagesection')),
],
options={
'verbose_name': 'Custom Page Section Item',
'verbose_name_plural': 'Custom Page Section Items',
'ordering': ['order'],
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2026-05-26 12:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0008_custom_pages'),
]
operations = [
migrations.AlterField(
model_name='homepagesection',
name='section_type',
field=models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products'), ('problems', 'Problems / Value Proposition'), ('about_strip', 'About Strip'), ('supporters', 'Supporters')], max_length=30),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.0.2 on 2026-05-26 12:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0009_alter_homepagesection_section_type'),
]
operations = [
migrations.AddField(
model_name='homepagesectionitem',
name='url',
field=models.CharField(blank=True, help_text='Optional link; makes this item clickable.', max_length=300),
),
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', 'Products Grid'), ('products_catalog', 'Products with Sub-products'), ('problems', 'Problems / Value Proposition'), ('supporters', 'Supporters'), ('about_strip', 'About Strip'), ('faq', 'FAQ Accordion')], default='custom', max_length=30),
),
migrations.AlterField(
model_name='custompagesectionitem',
name='url',
field=models.CharField(blank=True, help_text='Optional link; makes this item clickable.', max_length=300),
),
migrations.AlterField(
model_name='homepagesection',
name='section_type',
field=models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products'), ('products_catalog', 'Products with Sub-products'), ('problems', 'Problems / Value Proposition'), ('about_strip', 'About Strip'), ('supporters', 'Supporters')], max_length=30),
),
]
@@ -0,0 +1,48 @@
from django.db import migrations, models
import django.db.models.deletion
def set_custom_page_section_item_page(apps, schema_editor):
CustomPageSectionItem = apps.get_model("pages", "CustomPageSectionItem")
for item in CustomPageSectionItem.objects.select_related("section").iterator():
item.page_id = item.section.page_id
item.save(update_fields=["page_id"])
class Migration(migrations.Migration):
dependencies = [
("pages", "0010_homepagesectionitem_url_and_more"),
]
operations = [
migrations.AlterField(
model_name="aboutsectionitem",
name="url",
field=models.CharField(
blank=True,
help_text="Optional link; makes this item clickable.",
max_length=300,
),
),
migrations.AddField(
model_name="custompagesectionitem",
name="page",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="section_items",
to="pages.custompage",
),
),
migrations.RunPython(set_custom_page_section_item_page, migrations.RunPython.noop),
migrations.AlterField(
model_name="custompagesectionitem",
name="page",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="section_items",
to="pages.custompage",
),
),
]
@@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pages", "0011_custompagesectionitem_page_aboutsectionitem_url"),
]
operations = [
migrations.AddField(
model_name="aboutsectionitem",
name="icon",
field=models.CharField(
blank=True,
help_text="Emoji or short symbol (e.g. ⚗️).",
max_length=20,
),
),
]
@@ -0,0 +1,30 @@
# Generated by Django 5.0.2 on 2026-06-07 13:23
import apps.pages.contact_uploads
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0012_aboutsectionitem_icon'),
]
operations = [
migrations.CreateModel(
name='ContactSubmissionAttachment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(storage=apps.pages.contact_uploads.ContactAttachmentStorage(), upload_to=apps.pages.contact_uploads.contact_attachment_upload_to)),
('original_filename', models.CharField(max_length=255)),
('uploaded_at', models.DateTimeField(auto_now_add=True)),
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='pages.contactsubmission')),
],
options={
'verbose_name': 'Contact Attachment',
'verbose_name_plural': 'Contact Attachments',
'ordering': ['uploaded_at'],
},
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.2.13 on 2026-06-07 13:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0013_contact_submission_attachments'),
]
operations = [
migrations.AlterField(
model_name='aboutsection',
name='section_type',
field=models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('supporters', 'Supporters')], default='custom', max_length=30),
),
migrations.AlterField(
model_name='aboutsectionitem',
name='image',
field=models.ImageField(blank=True, help_text='Logo or image (used for Supporters cards).', null=True, upload_to='about/items/'),
),
]
+141
View File
@@ -0,0 +1,141 @@
# Generated by Django 5.2.13 on 2026-06-08 12:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0014_alter_aboutsection_section_type_and_more'),
]
operations = [
migrations.CreateModel(
name='PageVideo',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('video_source', models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20)),
('video_url', models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500)),
('video_file', models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/')),
('video_poster', models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/')),
('video_size', models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10)),
('video_aspect_ratio', models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10)),
('page', models.CharField(choices=[('contact', 'Contact'), ('faq', 'FAQ')], max_length=20)),
('badge', models.CharField(blank=True, max_length=100)),
('title', models.CharField(blank=True, max_length=300)),
('description', models.TextField(blank=True)),
('order', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
],
options={
'verbose_name': 'Page Video',
'verbose_name_plural': 'Page Videos',
'ordering': ['page', 'order'],
},
),
migrations.AddField(
model_name='aboutsection',
name='video_aspect_ratio',
field=models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10),
),
migrations.AddField(
model_name='aboutsection',
name='video_file',
field=models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/'),
),
migrations.AddField(
model_name='aboutsection',
name='video_poster',
field=models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/'),
),
migrations.AddField(
model_name='aboutsection',
name='video_size',
field=models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10),
),
migrations.AddField(
model_name='aboutsection',
name='video_source',
field=models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20),
),
migrations.AddField(
model_name='aboutsection',
name='video_url',
field=models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500),
),
migrations.AddField(
model_name='custompagesection',
name='video_aspect_ratio',
field=models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10),
),
migrations.AddField(
model_name='custompagesection',
name='video_file',
field=models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/'),
),
migrations.AddField(
model_name='custompagesection',
name='video_poster',
field=models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/'),
),
migrations.AddField(
model_name='custompagesection',
name='video_size',
field=models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10),
),
migrations.AddField(
model_name='custompagesection',
name='video_source',
field=models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20),
),
migrations.AddField(
model_name='custompagesection',
name='video_url',
field=models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500),
),
migrations.AddField(
model_name='homepagesection',
name='video_aspect_ratio',
field=models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10),
),
migrations.AddField(
model_name='homepagesection',
name='video_file',
field=models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/'),
),
migrations.AddField(
model_name='homepagesection',
name='video_poster',
field=models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/'),
),
migrations.AddField(
model_name='homepagesection',
name='video_size',
field=models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10),
),
migrations.AddField(
model_name='homepagesection',
name='video_source',
field=models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20),
),
migrations.AddField(
model_name='homepagesection',
name='video_url',
field=models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500),
),
migrations.AlterField(
model_name='aboutsection',
name='section_type',
field=models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('supporters', 'Supporters'), ('video', 'Video')], default='custom', max_length=30),
),
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', 'Products Grid'), ('products_catalog', 'Products with Sub-products'), ('problems', 'Problems / Value Proposition'), ('supporters', 'Supporters'), ('about_strip', 'About Strip'), ('faq', 'FAQ Accordion'), ('video', 'Video')], default='custom', max_length=30),
),
migrations.AlterField(
model_name='homepagesection',
name='section_type',
field=models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products'), ('products_catalog', 'Products with Sub-products'), ('problems', 'Problems / Value Proposition'), ('about_strip', 'About Strip'), ('supporters', 'Supporters'), ('video', 'Video')], max_length=30),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.2.13 on 2026-06-08 13:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0015_video_support'),
]
operations = [
migrations.AddField(
model_name='aboutsection',
name='video_styled_background',
field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'),
),
migrations.AddField(
model_name='custompagesection',
name='video_styled_background',
field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'),
),
migrations.AddField(
model_name='homepagesection',
name='video_styled_background',
field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'),
),
migrations.AddField(
model_name='pagevideo',
name='video_styled_background',
field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.2.13 on 2026-06-08 13:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0016_video_styled_background'),
]
operations = [
migrations.AddField(
model_name='aboutsection',
name='description_format',
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
),
migrations.AddField(
model_name='custompagesection',
name='description_format',
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
),
migrations.AddField(
model_name='homepagesection',
name='description_format',
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
),
migrations.AddField(
model_name='pagevideo',
name='description_format',
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2026-08-01 15:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0017_video_description_format'),
]
operations = [
migrations.AlterField(
model_name='herosection',
name='title',
field=models.CharField(blank=True, help_text="Main title line (e.g. 'Communication to Care,').", max_length=300),
),
]
+242 -7
View File
@@ -1,11 +1,29 @@
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.utils.text import slugify
from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_MARKDOWN, FORMAT_PLAIN, render_content from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_MARKDOWN, FORMAT_PLAIN, render_content
from apps.core.video import VideoBlockMixin
from apps.pages.contact_uploads import (
contact_attachment_storage,
contact_attachment_upload_to,
)
RESERVED_PAGE_SLUGS = frozenset({
"admin",
"about",
"contact",
"faq",
"home",
"products",
"static",
"media",
})
class HeroSection(models.Model): class HeroSection(models.Model):
badge = models.CharField(max_length=200, blank=True, help_text="Small label above the title (e.g. 'Developing since 2021').") badge = models.CharField(max_length=200, blank=True, help_text="Small label above the title (e.g. 'Developing since 2021').")
title = models.CharField(max_length=300, blank=True, help_text="Main title line (e.g. 'Radiuma,').") title = models.CharField(max_length=300, blank=True, help_text="Main title line (e.g. 'Communication to Care,').")
title_highlight = models.CharField(max_length=300, blank=True, help_text="Second title line shown in gradient colour (e.g. 'A Powerful Workflow Generator').") title_highlight = models.CharField(max_length=300, blank=True, help_text="Second title line shown in gradient colour (e.g. 'A Powerful Workflow Generator').")
subtitle = models.CharField(max_length=500, blank=True, help_text="Short line below the title (e.g. 'for Standardized Radiomics Analysis…').") subtitle = models.CharField(max_length=500, blank=True, help_text="Short line below the title (e.g. 'for Standardized Radiomics Analysis…').")
description = models.TextField(blank=True, help_text="Longer paragraph below the subtitle.") description = models.TextField(blank=True, help_text="Longer paragraph below the subtitle.")
@@ -24,24 +42,28 @@ class HeroSection(models.Model):
return "Hero Section" return "Hero Section"
class HomepageSection(models.Model): class HomepageSection(VideoBlockMixin, models.Model):
TYPE_FEATURES = "features" TYPE_FEATURES = "features"
TYPE_SCREENSHOTS = "screenshots" TYPE_SCREENSHOTS = "screenshots"
TYPE_PRODUCTS = "products" TYPE_PRODUCTS = "products"
TYPE_PRODUCTS_CATALOG = "products_catalog"
TYPE_PROBLEMS = "problems" TYPE_PROBLEMS = "problems"
TYPE_ABOUT_STRIP = "about_strip" TYPE_ABOUT_STRIP = "about_strip"
TYPE_SUPPORTERS = "supporters" TYPE_SUPPORTERS = "supporters"
TYPE_VIDEO = "video"
TYPE_CHOICES = [ TYPE_CHOICES = [
(TYPE_FEATURES, "Features"), (TYPE_FEATURES, "Features"),
(TYPE_SCREENSHOTS, "Screenshots Gallery"), (TYPE_SCREENSHOTS, "Screenshots Gallery"),
(TYPE_PRODUCTS, "Products"), (TYPE_PRODUCTS, "Products"),
(TYPE_PRODUCTS_CATALOG, "Products with Sub-products"),
(TYPE_PROBLEMS, "Problems / Value Proposition"), (TYPE_PROBLEMS, "Problems / Value Proposition"),
(TYPE_ABOUT_STRIP, "About Strip"), (TYPE_ABOUT_STRIP, "About Strip"),
(TYPE_SUPPORTERS, "Supporters"), (TYPE_SUPPORTERS, "Supporters"),
(TYPE_VIDEO, "Video"),
] ]
section_type = models.CharField(max_length=30, choices=TYPE_CHOICES, unique=True) section_type = models.CharField(max_length=30, choices=TYPE_CHOICES)
badge = models.CharField(max_length=100, blank=True) badge = models.CharField(max_length=100, blank=True)
title = models.CharField(max_length=300, blank=True) title = models.CharField(max_length=300, blank=True)
description = models.TextField(blank=True) description = models.TextField(blank=True)
@@ -55,6 +77,11 @@ class HomepageSection(models.Model):
verbose_name = "Homepage Section" verbose_name = "Homepage Section"
verbose_name_plural = "Homepage Sections" verbose_name_plural = "Homepage Sections"
def clean(self):
super().clean()
if self.section_type == self.TYPE_VIDEO:
self.clean_video_fields(require=True)
def __str__(self): def __str__(self):
return f"[{self.get_section_type_display()}] {self.title or self.badge}" return f"[{self.get_section_type_display()}] {self.title or self.badge}"
@@ -64,6 +91,7 @@ class HomepageSectionItem(models.Model):
icon = models.CharField(max_length=20, blank=True, help_text="Emoji or short symbol (e.g. ⚗️).") icon = models.CharField(max_length=20, blank=True, help_text="Emoji or short symbol (e.g. ⚗️).")
title = models.CharField(max_length=300, blank=True) title = models.CharField(max_length=300, blank=True)
content = models.TextField(blank=True) content = models.TextField(blank=True)
url = models.CharField(max_length=300, blank=True, help_text="Optional link; makes this item clickable.")
image = models.ImageField(upload_to="homepage/items/", blank=True, null=True, help_text="Logo or image (used for Supporters cards).") image = models.ImageField(upload_to="homepage/items/", blank=True, null=True, help_text="Logo or image (used for Supporters cards).")
order = models.PositiveIntegerField(default=0) order = models.PositiveIntegerField(default=0)
@@ -76,12 +104,14 @@ class HomepageSectionItem(models.Model):
return f"{self.section} {self.title or self.icon or '(item)'}" return f"{self.section} {self.title or self.icon or '(item)'}"
class AboutSection(models.Model): class AboutSection(VideoBlockMixin, models.Model):
TYPE_HERO = "hero" TYPE_HERO = "hero"
TYPE_INTRO = "intro" TYPE_INTRO = "intro"
TYPE_GRID = "grid" TYPE_GRID = "grid"
TYPE_HISTORY = "history" TYPE_HISTORY = "history"
TYPE_CUSTOM = "custom" TYPE_CUSTOM = "custom"
TYPE_SUPPORTERS = "supporters"
TYPE_VIDEO = "video"
TYPE_CHOICES = [ TYPE_CHOICES = [
(TYPE_HERO, "Hero"), (TYPE_HERO, "Hero"),
@@ -89,6 +119,8 @@ class AboutSection(models.Model):
(TYPE_GRID, "Grid Cards"), (TYPE_GRID, "Grid Cards"),
(TYPE_HISTORY, "History Block"), (TYPE_HISTORY, "History Block"),
(TYPE_CUSTOM, "Custom Content"), (TYPE_CUSTOM, "Custom Content"),
(TYPE_SUPPORTERS, "Supporters"),
(TYPE_VIDEO, "Video"),
] ]
section_type = models.CharField(max_length=30, choices=TYPE_CHOICES, default=TYPE_CUSTOM) section_type = models.CharField(max_length=30, choices=TYPE_CHOICES, default=TYPE_CUSTOM)
@@ -111,6 +143,11 @@ class AboutSection(models.Model):
def rendered_content(self): def rendered_content(self):
return render_content(self.content, self.content_format) return render_content(self.content, self.content_format)
def clean(self):
super().clean()
if self.section_type == self.TYPE_VIDEO:
self.clean_video_fields(require=True)
def __str__(self): def __str__(self):
label = self.title or self.badge or self.get_section_type_display() label = self.title or self.badge or self.get_section_type_display()
return f"[{self.get_section_type_display()}] {label}" return f"[{self.get_section_type_display()}] {label}"
@@ -118,11 +155,17 @@ class AboutSection(models.Model):
class AboutSectionItem(models.Model): class AboutSectionItem(models.Model):
section = models.ForeignKey(AboutSection, on_delete=models.CASCADE, related_name="items") section = models.ForeignKey(AboutSection, on_delete=models.CASCADE, related_name="items")
icon = models.CharField(max_length=20, blank=True, help_text="Emoji or short symbol (e.g. ⚗️).")
badge = models.CharField(max_length=100, blank=True) badge = models.CharField(max_length=100, blank=True)
title = models.CharField(max_length=300, blank=True) title = models.CharField(max_length=300, blank=True)
content = models.TextField(blank=True, help_text="Description text or link label for History links.") content = models.TextField(blank=True, help_text="Description text or link label for History links.")
url = models.URLField(blank=True, help_text="Used for History block links.") url = models.CharField(max_length=300, blank=True, help_text="Optional link; makes this item clickable.")
image = models.ImageField(upload_to="about/", blank=True, null=True) image = models.ImageField(
upload_to="about/items/",
blank=True,
null=True,
help_text="Logo or image (used for Supporters cards).",
)
image_alt = models.CharField(max_length=200, blank=True) image_alt = models.CharField(max_length=200, blank=True)
is_featured = models.BooleanField(default=False, help_text="Mark as featured item (e.g. large screenshot).") is_featured = models.BooleanField(default=False, help_text="Mark as featured item (e.g. large screenshot).")
order = models.PositiveIntegerField(default=0) order = models.PositiveIntegerField(default=0)
@@ -133,7 +176,7 @@ class AboutSectionItem(models.Model):
verbose_name_plural = "About Section Items" verbose_name_plural = "About Section Items"
def __str__(self): def __str__(self):
return f"{self.section} {self.title or self.badge or '(item)'}" return f"{self.section} {self.title or self.icon or self.badge or '(item)'}"
class ContactSubmission(models.Model): class ContactSubmission(models.Model):
@@ -153,6 +196,28 @@ class ContactSubmission(models.Model):
return f"{self.name}{self.title}" return f"{self.name}{self.title}"
class ContactSubmissionAttachment(models.Model):
submission = models.ForeignKey(
ContactSubmission,
on_delete=models.CASCADE,
related_name="attachments",
)
file = models.FileField(
upload_to=contact_attachment_upload_to,
storage=contact_attachment_storage,
)
original_filename = models.CharField(max_length=255)
uploaded_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["uploaded_at"]
verbose_name = "Contact Attachment"
verbose_name_plural = "Contact Attachments"
def __str__(self):
return self.original_filename
class FAQEntry(models.Model): class FAQEntry(models.Model):
question = models.CharField(max_length=500) question = models.CharField(max_length=500)
answer = models.TextField() answer = models.TextField()
@@ -204,3 +269,173 @@ class DownloadItem(models.Model):
def __str__(self): def __str__(self):
return f"{self.name} ({self.get_platform_display()})" return f"{self.name} ({self.get_platform_display()})"
class CustomPage(models.Model):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True)
menu_label = models.CharField(
max_length=100,
blank=True,
help_text="Nav label when shown in menu. Defaults to title.",
)
meta_description = models.CharField(max_length=300, blank=True)
show_in_nav = models.BooleanField(default=True)
menu_order = models.PositiveIntegerField(default=0)
is_published = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["menu_order", "title"]
verbose_name = "Custom Page"
verbose_name_plural = "Custom Pages"
def __str__(self):
return self.title
@property
def nav_label(self):
return self.menu_label or self.title
def clean(self):
super().clean()
if self.slug in RESERVED_PAGE_SLUGS:
raise ValidationError({"slug": f'"{self.slug}" is reserved and cannot be used.'})
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title)
self.full_clean()
super().save(*args, **kwargs)
class CustomPageSection(VideoBlockMixin, models.Model):
TYPE_HERO = "hero"
TYPE_INTRO = "intro"
TYPE_GRID = "grid"
TYPE_HISTORY = "history"
TYPE_CUSTOM = "custom"
TYPE_FEATURES = "features"
TYPE_SCREENSHOTS = "screenshots"
TYPE_PRODUCTS = "products"
TYPE_PRODUCTS_CATALOG = "products_catalog"
TYPE_PROBLEMS = "problems"
TYPE_SUPPORTERS = "supporters"
TYPE_ABOUT_STRIP = "about_strip"
TYPE_FAQ = "faq"
TYPE_VIDEO = "video"
TYPE_CHOICES = [
(TYPE_HERO, "Hero"),
(TYPE_INTRO, "Intro Card"),
(TYPE_GRID, "Grid Cards"),
(TYPE_HISTORY, "History Block"),
(TYPE_CUSTOM, "Custom Content"),
(TYPE_FEATURES, "Features Grid"),
(TYPE_SCREENSHOTS, "Screenshots Gallery"),
(TYPE_PRODUCTS, "Products Grid"),
(TYPE_PRODUCTS_CATALOG, "Products with Sub-products"),
(TYPE_PROBLEMS, "Problems / Value Proposition"),
(TYPE_SUPPORTERS, "Supporters"),
(TYPE_ABOUT_STRIP, "About Strip"),
(TYPE_FAQ, "FAQ Accordion"),
(TYPE_VIDEO, "Video"),
]
page = models.ForeignKey(CustomPage, on_delete=models.CASCADE, related_name="sections")
section_type = models.CharField(max_length=30, choices=TYPE_CHOICES, default=TYPE_CUSTOM)
badge = models.CharField(max_length=100, blank=True)
title = models.CharField(max_length=300, blank=True)
subtitle = models.CharField(max_length=500, blank=True)
description = models.TextField(blank=True, help_text="Short intro text (homepage-style sections).")
content = models.TextField(blank=True)
content_format = models.CharField(max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_MARKDOWN)
link_text = models.CharField(max_length=100, blank=True)
link_url = models.CharField(max_length=300, blank=True)
order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ["order"]
verbose_name = "Custom Page Section"
verbose_name_plural = "Custom Page Sections"
@property
def rendered_content(self):
return render_content(self.content, self.content_format)
def clean(self):
super().clean()
if self.section_type == self.TYPE_VIDEO:
self.clean_video_fields(require=True)
def __str__(self):
label = self.title or self.badge or self.get_section_type_display()
return f"{self.page} [{self.get_section_type_display()}] {label}"
class PageVideo(VideoBlockMixin, models.Model):
PAGE_CONTACT = "contact"
PAGE_FAQ = "faq"
PAGE_CHOICES = [
(PAGE_CONTACT, "Contact"),
(PAGE_FAQ, "FAQ"),
]
page = models.CharField(max_length=20, choices=PAGE_CHOICES)
badge = models.CharField(max_length=100, blank=True)
title = models.CharField(max_length=300, blank=True)
description = models.TextField(blank=True)
order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ["page", "order"]
verbose_name = "Page Video"
verbose_name_plural = "Page Videos"
def clean(self):
super().clean()
self.clean_video_fields(require=True)
def __str__(self):
label = self.title or self.badge or self.get_page_display()
return f"{self.get_page_display()} {label}"
class CustomPageSectionItem(models.Model):
page = models.ForeignKey(
CustomPage,
on_delete=models.CASCADE,
related_name="section_items",
)
section = models.ForeignKey(CustomPageSection, on_delete=models.CASCADE, related_name="items")
icon = models.CharField(max_length=20, blank=True)
badge = models.CharField(max_length=100, blank=True)
title = models.CharField(max_length=300, blank=True)
content = models.TextField(blank=True)
content_format = models.CharField(max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN)
url = models.CharField(max_length=300, blank=True, help_text="Optional link; makes this item clickable.")
image = models.ImageField(upload_to="pages/custom/", blank=True, null=True)
image_alt = models.CharField(max_length=200, blank=True)
is_featured = models.BooleanField(default=False)
order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["order"]
verbose_name = "Custom Page Section Item"
verbose_name_plural = "Custom Page Section Items"
@property
def rendered_content(self):
return render_content(self.content, self.content_format)
def save(self, *args, **kwargs):
if self.section_id:
self.page_id = self.section.page_id
super().save(*args, **kwargs)
def __str__(self):
return f"{self.section} {self.title or self.icon or '(item)'}"
+158
View File
@@ -0,0 +1,158 @@
import io
import zipfile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase, override_settings
from django.urls import reverse
from PIL import Image
from apps.pages.contact_uploads import (
MAX_ATTACHMENTS,
MAX_FILE_SIZE,
validate_contact_attachments,
)
from apps.pages.models import ContactSubmission, ContactSubmissionAttachment
def _png_file(name="screenshot.png"):
buffer = io.BytesIO()
Image.new("RGB", (8, 8), color="red").save(buffer, format="PNG")
buffer.seek(0)
return SimpleUploadedFile(name, buffer.read(), content_type="image/png")
def _zip_file(name="logs.zip", entries=None):
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
for entry_name, content in (entries or {"app.log": "line one\n"}).items():
archive.writestr(entry_name, content)
buffer.seek(0)
return SimpleUploadedFile(name, buffer.read(), content_type="application/zip")
class ContactUploadValidationTest(TestCase):
def test_accepts_valid_png(self):
validated = validate_contact_attachments([_png_file()])
self.assertEqual(len(validated), 1)
self.assertEqual(validated[0][1], "screenshot.png")
def test_accepts_valid_zip(self):
validated = validate_contact_attachments([_zip_file()])
self.assertEqual(len(validated), 1)
def test_accepts_valid_text_log(self):
uploaded = SimpleUploadedFile(
"error.log",
b"2026-06-07 ERROR something failed\n",
content_type="text/plain",
)
validated = validate_contact_attachments([uploaded])
self.assertEqual(validated[0][1], "error.log")
def test_rejects_executable_extension(self):
uploaded = SimpleUploadedFile(
"malware.exe",
b"MZfake",
content_type="application/octet-stream",
)
with self.assertRaises(Exception):
validate_contact_attachments([uploaded])
def test_rejects_php_disguised_as_png(self):
uploaded = SimpleUploadedFile(
"image.png",
b"<?php echo 'bad'; ?>",
content_type="image/png",
)
with self.assertRaises(Exception):
validate_contact_attachments([uploaded])
def test_rejects_oversized_file(self):
uploaded = SimpleUploadedFile(
"big.log",
b"x" * (MAX_FILE_SIZE + 1),
content_type="text/plain",
)
with self.assertRaises(Exception):
validate_contact_attachments([uploaded])
def test_rejects_too_many_files(self):
files = [_png_file(f"shot-{index}.png") for index in range(MAX_ATTACHMENTS + 1)]
with self.assertRaises(Exception):
validate_contact_attachments(files)
def test_rejects_zip_with_path_traversal(self):
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
archive.writestr("../escape.txt", "bad")
buffer.seek(0)
uploaded = SimpleUploadedFile(
"bad.zip",
buffer.read(),
content_type="application/zip",
)
with self.assertRaises(Exception):
validate_contact_attachments([uploaded])
@override_settings(
CONTACT_UPLOAD_ROOT=__import__("pathlib").Path(__file__).resolve().parents[3]
/ "test_private_uploads"
)
class ContactViewUploadTest(TestCase):
def setUp(self):
self.client = Client(enforce_csrf_checks=True)
self.url = reverse("pages:contact")
def _start_session(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.csrf_token = response.cookies["csrftoken"].value
captcha_question = response.context["captcha_question"]
left, right = captcha_question.split(" + ")
return int(left) + int(right)
def _post_contact(self, captcha_answer, attachments=None, extra=None):
payload = {
"name": "Test User",
"title": "Upload test",
"description": "Testing attachments",
"email": "test@example.com",
"captcha_answer": captcha_answer,
}
if attachments is not None:
payload["attachments"] = attachments
if extra:
payload.update(extra)
return self.client.post(
self.url,
data=payload,
HTTP_X_CSRFTOKEN=self.csrf_token,
)
def test_contact_submission_with_png_attachment(self):
captcha_answer = self._start_session()
response = self._post_contact(captcha_answer, attachments=_png_file())
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["success"])
submission = ContactSubmission.objects.get(title="Upload test")
self.assertEqual(submission.attachments.count(), 1)
attachment = submission.attachments.first()
self.assertEqual(attachment.original_filename, "screenshot.png")
self.assertTrue(attachment.file.storage.exists(attachment.file.name))
def test_contact_submission_rejects_invalid_attachment(self):
captcha_answer = self._start_session()
response = self._post_contact(
captcha_answer,
attachments=SimpleUploadedFile(
"bad.exe",
b"MZ",
content_type="application/octet-stream",
),
)
self.assertEqual(response.status_code, 400)
self.assertFalse(response.json()["success"])
self.assertIn("attachments", response.json()["errors"])
self.assertEqual(ContactSubmission.objects.count(), 0)
self.assertEqual(ContactSubmissionAttachment.objects.count(), 0)
+94
View File
@@ -0,0 +1,94 @@
from django.test import TestCase
from django.urls import reverse
from apps.pages.models import CustomPage, CustomPageSection, CustomPageSectionItem
class CustomPageViewTest(TestCase):
def setUp(self):
self.page = CustomPage.objects.create(
title="Resources",
slug="resources",
show_in_nav=True,
menu_order=5,
is_published=True,
)
CustomPageSection.objects.create(
page=self.page,
section_type=CustomPageSection.TYPE_HERO,
title="Resources",
badge="Docs",
is_active=True,
)
CustomPage.objects.create(
title="Draft Page",
slug="draft",
is_published=False,
)
def test_published_page_returns_200(self):
response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "resources"}))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "pages/custom_page.html")
self.assertEqual(response.context["custom_page"], self.page)
def test_unpublished_page_returns_404(self):
response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "draft"}))
self.assertEqual(response.status_code, 404)
def test_unknown_slug_returns_404(self):
response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "missing"}))
self.assertEqual(response.status_code, 404)
def test_section_item_with_url_renders_as_link(self):
section = CustomPageSection.objects.create(
page=self.page,
section_type=CustomPageSection.TYPE_FEATURES,
title="Highlights",
is_active=True,
)
CustomPageSectionItem.objects.create(
page=self.page,
section=section,
title="Documentation",
content="Read the docs.",
url="/about/",
order=1,
)
response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "resources"}))
self.assertContains(response, 'href="/about/"')
self.assertContains(response, "section-item-link")
self.assertContains(response, "Documentation")
class CustomPageNavTest(TestCase):
def test_nav_custom_pages_in_context(self):
CustomPage.objects.create(
title="Visible",
slug="visible",
show_in_nav=True,
menu_order=1,
is_published=True,
)
CustomPage.objects.create(
title="Hidden Nav",
slug="hidden-nav",
show_in_nav=False,
is_published=True,
)
response = self.client.get(reverse("pages:home"))
pages = list(response.context["nav_custom_pages"])
self.assertEqual(len(pages), 1)
self.assertEqual(pages[0].slug, "visible")
def test_nav_link_rendered(self):
CustomPage.objects.create(
title="Team",
slug="team",
menu_label="Our Team",
show_in_nav=True,
is_published=True,
)
response = self.client.get(reverse("pages:home"))
self.assertContains(response, "Our Team")
self.assertContains(response, reverse("pages:custom_page", kwargs={"slug": "team"}))
+260
View File
@@ -0,0 +1,260 @@
from django.test import TestCase
from django.urls import reverse
from apps.core.video import parse_youtube_video_id, youtube_embed_url
from apps.pages.models import (
AboutSection,
CustomPage,
CustomPageSection,
HomepageSection,
PageVideo,
)
from apps.products.models import MainProduct, ProductVideo
class YouTubeParsingTest(TestCase):
def test_watch_url(self):
self.assertEqual(
parse_youtube_video_id("https://www.youtube.com/watch?v=dQw4w9WgXcQ"),
"dQw4w9WgXcQ",
)
def test_short_url(self):
self.assertEqual(
parse_youtube_video_id("https://youtu.be/dQw4w9WgXcQ"),
"dQw4w9WgXcQ",
)
def test_embed_url(self):
url = youtube_embed_url("https://youtu.be/dQw4w9WgXcQ")
self.assertEqual(url, "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ")
class HomepageVideoSectionTest(TestCase):
def test_video_section_renders_youtube_embed(self):
HomepageSection.objects.create(
section_type=HomepageSection.TYPE_VIDEO,
title="Demo",
video_source="youtube",
video_url="https://www.youtube.com/watch?v=dQw4w9WgXcQ",
is_active=True,
)
response = self.client.get(reverse("pages:home"))
self.assertContains(response, "youtube-nocookie.com/embed/dQw4w9WgXcQ")
self.assertContains(response, "video-block--md")
class AboutVideoSectionTest(TestCase):
def test_video_section_renders_on_about_page(self):
AboutSection.objects.create(
section_type=AboutSection.TYPE_VIDEO,
title="Overview",
video_source="youtube",
video_url="https://youtu.be/dQw4w9WgXcQ",
video_size="lg",
is_active=True,
)
response = self.client.get(reverse("pages:about"))
self.assertContains(response, "video-block--lg")
self.assertContains(response, "Overview")
class CustomPageVideoSectionTest(TestCase):
def test_video_section_renders_on_custom_page(self):
page = CustomPage.objects.create(
title="Media",
slug="media-page",
is_published=True,
)
CustomPageSection.objects.create(
page=page,
section_type=CustomPageSection.TYPE_VIDEO,
title="Walkthrough",
video_source="youtube",
video_url="https://youtu.be/dQw4w9WgXcQ",
is_active=True,
)
response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "media-page"}))
self.assertContains(response, "Walkthrough")
self.assertContains(response, "iframe")
class PageVideoTest(TestCase):
def _create_page_videos(self, page, count):
for index in range(count):
PageVideo.objects.create(
page=page,
title=f"Video {index + 1}",
video_source="youtube",
video_url="https://youtu.be/dQw4w9WgXcQ",
order=index,
is_active=True,
)
def test_contact_page_video(self):
PageVideo.objects.create(
page=PageVideo.PAGE_CONTACT,
title="Intro",
video_source="youtube",
video_url="https://youtu.be/dQw4w9WgXcQ",
is_active=True,
)
response = self.client.get(reverse("pages:contact"))
self.assertContains(response, "Intro")
self.assertContains(response, "youtube-nocookie.com/embed/dQw4w9WgXcQ")
def test_faq_page_video(self):
PageVideo.objects.create(
page=PageVideo.PAGE_FAQ,
title="Tutorial",
video_source="youtube",
video_url="https://youtu.be/dQw4w9WgXcQ",
is_active=True,
)
response = self.client.get(reverse("pages:faq"))
self.assertContains(response, "Tutorial")
def test_page_shows_three_video_preview_and_archive_link(self):
self._create_page_videos(PageVideo.PAGE_FAQ, 8)
response = self.client.get(reverse("pages:faq"))
self.assertEqual(response.content.count(b"youtube-nocookie.com/embed"), 3)
self.assertContains(
response,
reverse(
"pages:video_archive",
kwargs={"library": PageVideo.PAGE_FAQ},
),
)
self.assertContains(response, "More videos")
def test_page_video_archive_is_paginated(self):
self._create_page_videos(PageVideo.PAGE_FAQ, 8)
archive_url = reverse(
"pages:video_archive",
kwargs={"library": PageVideo.PAGE_FAQ},
)
first_page = self.client.get(archive_url)
second_page = self.client.get(archive_url, {"page": 2})
self.assertEqual(first_page.status_code, 200)
self.assertEqual(first_page.content.count(b"youtube-nocookie.com/embed"), 6)
self.assertContains(first_page, "Page 1 of 2")
self.assertContains(first_page, "?page=2")
self.assertEqual(second_page.content.count(b"youtube-nocookie.com/embed"), 2)
self.assertContains(second_page, "Page 2 of 2")
def test_unknown_page_video_archive_returns_404(self):
response = self.client.get(
reverse("pages:video_archive", kwargs={"library": "unknown"})
)
self.assertEqual(response.status_code, 404)
class ProductVideoTest(TestCase):
def setUp(self):
self.main_product = MainProduct.objects.create(
name="Communication to Care",
slug="com2care",
short_description="Short",
description="Long",
is_active=True,
)
def test_product_video_renders_on_detail_page(self):
ProductVideo.objects.create(
main_product=self.main_product,
title="Product Demo",
video_source="youtube",
video_url="https://youtu.be/dQw4w9WgXcQ",
video_size="full",
is_active=True,
)
response = self.client.get(
reverse("products:main_product_detail", kwargs={"main_slug": "com2care"})
)
self.assertContains(response, "Product Demo")
self.assertContains(response, "video-block--full")
def test_product_video_preview_links_to_paginated_archive(self):
for index in range(7):
ProductVideo.objects.create(
main_product=self.main_product,
title=f"Product video {index + 1}",
video_source="youtube",
video_url="https://youtu.be/dQw4w9WgXcQ",
order=index,
is_active=True,
)
archive_url = reverse(
"products:main_product_videos",
kwargs={"main_slug": self.main_product.slug},
)
detail_response = self.client.get(self.main_product.get_absolute_url())
archive_response = self.client.get(archive_url)
second_page = self.client.get(archive_url, {"page": 2})
self.assertEqual(detail_response.content.count(b"youtube-nocookie.com/embed"), 3)
self.assertContains(detail_response, archive_url)
self.assertEqual(archive_response.content.count(b"youtube-nocookie.com/embed"), 6)
self.assertEqual(second_page.content.count(b"youtube-nocookie.com/embed"), 1)
class VideoStyledBackgroundTest(TestCase):
def test_styled_background_renders_panel(self):
HomepageSection.objects.create(
section_type=HomepageSection.TYPE_VIDEO,
title="Styled Demo",
video_source="youtube",
video_url="https://youtu.be/dQw4w9WgXcQ",
video_styled_background=True,
is_active=True,
)
response = self.client.get(reverse("pages:home"))
self.assertContains(response, "video-panel--styled")
self.assertContains(response, "video-panel-blob")
def test_plain_background_without_panel(self):
HomepageSection.objects.create(
section_type=HomepageSection.TYPE_VIDEO,
title="Plain Demo",
video_source="youtube",
video_url="https://youtu.be/dQw4w9WgXcQ",
video_styled_background=False,
is_active=True,
)
response = self.client.get(reverse("pages:home"))
self.assertNotContains(response, "video-panel--styled")
self.assertContains(response, "Plain Demo")
class VideoDescriptionFormatTest(TestCase):
def test_plain_description_preserves_line_breaks(self):
HomepageSection.objects.create(
section_type=HomepageSection.TYPE_VIDEO,
title="Demo",
description="First line\nSecond line",
video_source="youtube",
video_url="https://youtu.be/dQw4w9WgXcQ",
is_active=True,
)
response = self.client.get(reverse("pages:home"))
self.assertContains(response, "First line")
self.assertContains(response, "Second line")
self.assertContains(response, "<br>")
def test_markdown_description_renders(self):
PageVideo.objects.create(
page=PageVideo.PAGE_FAQ,
title="Guide",
description="**Bold** intro",
description_format="markdown",
video_source="youtube",
video_url="https://youtu.be/dQw4w9WgXcQ",
is_active=True,
)
response = self.client.get(reverse("pages:faq"))
self.assertContains(response, "<strong>Bold</strong>")
+15
View File
@@ -80,3 +80,18 @@ class NavigationContextTest(TestCase):
response.context, response.context,
f"Missing nav_main_products at {url}", f"Missing nav_main_products at {url}",
) )
def test_nav_custom_pages_in_context_on_all_pages(self):
urls = [
reverse("pages:home"),
reverse("pages:about"),
reverse("pages:faq"),
reverse("pages:contact"),
]
for url in urls:
response = self.client.get(url)
self.assertIn(
"nav_custom_pages",
response.context,
f"Missing nav_custom_pages at {url}",
)
+6
View File
@@ -9,4 +9,10 @@ urlpatterns = [
path("about/", views.AboutView.as_view(), name="about"), path("about/", views.AboutView.as_view(), name="about"),
path("faq/", views.FAQView.as_view(), name="faq"), path("faq/", views.FAQView.as_view(), name="faq"),
path("contact/", views.ContactView.as_view(), name="contact"), path("contact/", views.ContactView.as_view(), name="contact"),
path(
"videos/<slug:library>/",
views.PageVideoArchiveView.as_view(),
name="video_archive",
),
path("<slug>/", views.CustomPageView.as_view(), name="custom_page"),
] ]
+165 -10
View File
@@ -1,14 +1,50 @@
import random import random
from django.http import JsonResponse from django.db.models import Prefetch
from django.http import Http404, JsonResponse
from django.shortcuts import render from django.shortcuts import render
from django.urls import reverse
from django.views import View from django.views import View
from django.views.generic import ListView, TemplateView from django.views.generic import DetailView, ListView, TemplateView
from apps.products.models import SubProduct from apps.products.models import MainProduct, SubProduct
from .forms import ContactForm from .forms import ContactForm
from .models import AboutSection, ContactSubmission, FAQEntry, HeroSection, HomepageSection from .models import (
AboutSection,
ContactSubmission,
ContactSubmissionAttachment,
CustomPage,
FAQEntry,
HeroSection,
HomepageSection,
PageVideo,
)
VIDEO_PREVIEW_LIMIT = 3
VIDEO_ARCHIVE_PAGE_SIZE = 6
def _video_preview_context(queryset, archive_url):
total = queryset.count()
return {
"videos": queryset[:VIDEO_PREVIEW_LIMIT],
"videos_total_count": total,
"videos_archive_url": archive_url if total > VIDEO_PREVIEW_LIMIT else "",
}
def _homepage_products_catalog_queryset():
return (
MainProduct.objects.filter(is_active=True)
.prefetch_related(
Prefetch(
"sub_products",
queryset=SubProduct.objects.filter(is_active=True).order_by("order", "name"),
)
)
.order_by("order", "name")
)
class HomeView(TemplateView): class HomeView(TemplateView):
@@ -22,11 +58,11 @@ class HomeView(TemplateView):
.prefetch_related("items") .prefetch_related("items")
.order_by("order") .order_by("order")
) )
ctx["sub_products"] = ( ctx["homepage_products"] = (
SubProduct.objects.filter(is_active=True, show_on_homepage=True) MainProduct.objects.filter(is_active=True, show_on_homepage=True)
.select_related("main_product")
.order_by("homepage_order", "order") .order_by("homepage_order", "order")
) )
ctx["homepage_products_catalog"] = _homepage_products_catalog_queryset()
return ctx return ctx
@@ -49,6 +85,24 @@ class FAQView(ListView):
context_object_name = "faq_entries" context_object_name = "faq_entries"
queryset = FAQEntry.objects.filter(is_active=True) 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): class ContactView(View):
template_name = "pages/contact.html" template_name = "pages/contact.html"
@@ -59,14 +113,34 @@ class ContactView(View):
return f"{a} + {b}" return f"{a} + {b}"
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
videos = PageVideo.objects.filter(
page=PageVideo.PAGE_CONTACT,
is_active=True,
).order_by("order", "pk")
preview = _video_preview_context(
videos,
reverse(
"pages:video_archive",
kwargs={"library": PageVideo.PAGE_CONTACT},
),
)
return render( return render(
request, request,
self.template_name, self.template_name,
{"form": ContactForm(), "captcha_question": self._new_captcha(request)}, {
"form": ContactForm(),
"captcha_question": self._new_captcha(request),
"contact_videos": preview["videos"],
"videos_total_count": preview["videos_total_count"],
"videos_archive_url": preview["videos_archive_url"],
},
) )
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
form = ContactForm(request.POST) form = ContactForm(
request.POST,
file_list=request.FILES.getlist("attachments"),
)
expected = request.session.get("captcha_answer") expected = request.session.get("captcha_answer")
captcha_question = self._new_captcha(request) captcha_question = self._new_captcha(request)
@@ -77,7 +151,15 @@ class ContactView(View):
pass pass
if form.is_valid() and captcha_ok: if form.is_valid() and captcha_ok:
form.save() submission = form.save()
for uploaded_file, original_name in form.cleaned_data.get(
"attachments", []
):
attachment = ContactSubmissionAttachment(
submission=submission,
original_filename=original_name,
)
attachment.file.save(original_name, uploaded_file, save=True)
return JsonResponse({"success": True}) return JsonResponse({"success": True})
errors: dict = {} errors: dict = {}
@@ -90,3 +172,76 @@ class ContactView(View):
{"success": False, "errors": errors, "captcha_question": captcha_question}, {"success": False, "errors": errors, "captcha_question": captcha_question},
status=400, status=400,
) )
class CustomPageView(DetailView):
model = CustomPage
template_name = "pages/custom_page.html"
context_object_name = "custom_page"
slug_url_kwarg = "slug"
def get_queryset(self):
return CustomPage.objects.filter(is_published=True)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["page_sections"] = (
self.object.sections.filter(is_active=True)
.prefetch_related("items")
.order_by("order")
)
ctx["homepage_products"] = (
MainProduct.objects.filter(is_active=True, show_on_homepage=True)
.order_by("homepage_order", "order")
)
ctx["homepage_products_catalog"] = _homepage_products_catalog_queryset()
return ctx
class PageVideoArchiveView(ListView):
model = PageVideo
template_name = "videos/archive.html"
context_object_name = "videos"
paginate_by = VIDEO_ARCHIVE_PAGE_SIZE
PAGE_CONFIG = {
PageVideo.PAGE_CONTACT: (
"Contact videos",
"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):
try:
return self.PAGE_CONFIG[self.kwargs["library"]]
except KeyError as exc:
raise Http404("Video library not found.") from exc
def get_queryset(self):
self.get_page_config()
return PageVideo.objects.filter(
page=self.kwargs["library"],
is_active=True,
).order_by("order", "pk")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
title, description, source_url_name = self.get_page_config()
context.update(
{
"library_title": title,
"library_description": description,
"library_back_url": reverse(source_url_name),
"library_back_label": "Back to page",
"pagination_range": context["paginator"].get_elided_page_range(
context["page_obj"].number
),
}
)
return context
+143 -13
View File
@@ -1,6 +1,9 @@
from django.contrib import admin from django.contrib import admin
from .models import Article, ArticleCitation, ArticleSection, MainProduct, SubProduct, SubProductVersion 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
class ArticleCitationInline(admin.TabularInline): class ArticleCitationInline(admin.TabularInline):
@@ -37,7 +40,10 @@ class MainProductArticleInline(admin.StackedInline):
RELEASE_VERSION_INLINE_FIELDS = ( RELEASE_VERSION_INLINE_FIELDS = (
"version", "version",
"is_featured_stable", "release_channel",
"channel_label",
"is_featured",
"show_on_product_page",
"windows_download_url", "windows_download_url",
"macos_download_url", "macos_download_url",
"linux_download_url", "linux_download_url",
@@ -55,6 +61,7 @@ class SubProductVersionInline(admin.TabularInline):
extra = 0 extra = 0
ordering = ("order", "version") ordering = ("order", "version")
fields = RELEASE_VERSION_INLINE_FIELDS fields = RELEASE_VERSION_INLINE_FIELDS
show_change_link = True
class MainProductVersionInline(admin.TabularInline): class MainProductVersionInline(admin.TabularInline):
@@ -63,6 +70,29 @@ class MainProductVersionInline(admin.TabularInline):
extra = 0 extra = 0
ordering = ("order", "version") ordering = ("order", "version")
fields = RELEASE_VERSION_INLINE_FIELDS fields = RELEASE_VERSION_INLINE_FIELDS
show_change_link = True
class ProductVideoInline(admin.StackedInline):
model = ProductVideo
fk_name = "main_product"
extra = 0
fields = (
"badge",
"title",
"description_format",
"description",
"video_source",
"video_url",
"video_file",
"video_poster",
"video_size",
"video_aspect_ratio",
"video_styled_background",
"order",
"is_active",
)
ordering = ("order",)
class SubProductInline(admin.StackedInline): class SubProductInline(admin.StackedInline):
@@ -76,20 +106,57 @@ class SubProductInline(admin.StackedInline):
@admin.register(MainProduct) @admin.register(MainProduct)
class MainProductAdmin(admin.ModelAdmin): class MainProductAdmin(admin.ModelAdmin):
list_display = ("name", "order", "is_active", "created_at") list_display = ("name", "show_on_homepage", "homepage_order", "order", "is_active", "created_at")
list_filter = ("is_active",) list_filter = ("is_active", "show_on_homepage")
search_fields = ("name", "description") search_fields = ("name", "description")
prepopulated_fields = {"slug": ("name",)} prepopulated_fields = {"slug": ("name",)}
list_editable = ("order", "is_active") list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
inlines = [MainProductArticleInline, MainProductVersionInline, SubProductInline] inlines = [MainProductArticleInline, ProductVideoInline, MainProductVersionInline, SubProductInline]
fieldsets = ( fieldsets = (
(None, {"fields": ("name", "slug", "short_description", "distribution")}), (
None,
{
"fields": (
"name",
"slug",
"short_description",
"distribution",
"package_resource_button_text",
"package_source_button_text",
"downloads_section_link_text",
"downloads_section_link_url",
)
},
),
("Description", {"fields": ("description_format", "description")}), ("Description", {"fields": ("description_format", "description")}),
("Media", {"fields": ("image",)}), ("Media", {"fields": ("image",)}),
("Homepage", {"fields": ("show_on_homepage", "homepage_order")}),
("Settings", {"fields": ("order", "is_active")}), ("Settings", {"fields": ("order", "is_active")}),
) )
class SubProductVideoInline(admin.StackedInline):
model = ProductVideo
fk_name = "sub_product"
extra = 0
fields = (
"badge",
"title",
"description_format",
"description",
"video_source",
"video_url",
"video_file",
"video_poster",
"video_size",
"video_aspect_ratio",
"video_styled_background",
"order",
"is_active",
)
ordering = ("order",)
@admin.register(SubProduct) @admin.register(SubProduct)
class SubProductAdmin(admin.ModelAdmin): class SubProductAdmin(admin.ModelAdmin):
list_display = ( list_display = (
@@ -107,9 +174,24 @@ class SubProductAdmin(admin.ModelAdmin):
prepopulated_fields = {"slug": ("name",)} prepopulated_fields = {"slug": ("name",)}
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order") list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
raw_id_fields = ("main_product",) raw_id_fields = ("main_product",)
inlines = [SubProductArticleInline, SubProductVersionInline] inlines = [SubProductArticleInline, SubProductVideoInline, SubProductVersionInline]
fieldsets = ( fieldsets = (
(None, {"fields": ("main_product", "name", "slug", "distribution", "short_description")}), (
None,
{
"fields": (
"main_product",
"name",
"slug",
"distribution",
"short_description",
"package_resource_button_text",
"package_source_button_text",
"downloads_section_link_text",
"downloads_section_link_url",
)
},
),
("Description", {"fields": ("description_format", "description")}), ("Description", {"fields": ("description_format", "description")}),
("Media", {"fields": ("image", "logo")}), ("Media", {"fields": ("image", "logo")}),
("Homepage", {"fields": ("show_on_homepage", "homepage_order")}), ("Homepage", {"fields": ("show_on_homepage", "homepage_order")}),
@@ -141,6 +223,33 @@ class ArticleAdmin(admin.ModelAdmin):
return "" return ""
@admin.register(ProductVideo)
class ProductVideoAdmin(admin.ModelAdmin):
list_display = ("title", "parent", "video_source", "video_size", "order", "is_active")
list_filter = ("video_source", "is_active", "main_product", "sub_product__main_product")
list_editable = ("order", "is_active")
search_fields = ("title", "description", "video_url", "main_product__name", "sub_product__name")
raw_id_fields = ("main_product", "sub_product")
readonly_fields = ("video_preview",)
fieldsets = (
(None, {"fields": ("main_product", "sub_product", "badge", "title", "description_format", "description")}),
VIDEO_ADMIN_FIELDSET,
("Settings", {"fields": ("order", "is_active")}),
)
@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 ""
@admin.display(description="Preview")
def video_preview(self, obj):
return video_admin_preview(obj)
@admin.register(ArticleSection) @admin.register(ArticleSection)
class ArticleSectionAdmin(admin.ModelAdmin): class ArticleSectionAdmin(admin.ModelAdmin):
list_display = ("title", "article", "value_format", "order") list_display = ("title", "article", "value_format", "order")
@@ -159,11 +268,14 @@ class SubProductVersionAdmin(admin.ModelAdmin):
list_display = ( list_display = (
"parent", "parent",
"version", "version",
"is_featured_stable", "release_channel",
"channel_label",
"is_featured",
"show_on_product_page",
"is_active", "is_active",
"order", "order",
) )
list_filter = ("is_active", "is_featured_stable", "main_product", "sub_product__main_product") 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") search_fields = ("main_product__name", "sub_product__name", "version")
list_editable = ("is_active", "order") list_editable = ("is_active", "order")
raw_id_fields = ("main_product", "sub_product") raw_id_fields = ("main_product", "sub_product")
@@ -171,23 +283,41 @@ class SubProductVersionAdmin(admin.ModelAdmin):
( (
None, 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": ( "fields": (
"main_product", "main_product",
"sub_product", "sub_product",
"version", "version",
"is_featured_stable", "release_channel",
"channel_label",
"is_featured",
"show_on_product_page",
) )
}, },
), ),
( (
"Installable downloads", "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": ( "fields": (
"windows_download_url", "windows_download_url",
"windows_download_file",
"macos_download_url", "macos_download_url",
"macos_download_file",
"linux_download_url", "linux_download_url",
"linux_download_file",
"source_code_url", "source_code_url",
) "source_code_file",
),
}, },
), ),
("Package link", {"fields": ("package_resource_url",)}), ("Package link", {"fields": ("package_resource_url",)}),
+3
View File
@@ -5,3 +5,6 @@ class ProductsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "apps.products" name = "apps.products"
verbose_name = "Products" verbose_name = "Products"
def ready(self):
from . import signals # noqa: F401
@@ -0,0 +1,23 @@
# Generated by Django 5.0.2 on 2026-05-26 12:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0010_release_version_main_product'),
]
operations = [
migrations.AddField(
model_name='mainproduct',
name='homepage_order',
field=models.PositiveIntegerField(default=0, help_text='Order within the homepage Products section.'),
),
migrations.AddField(
model_name='mainproduct',
name='show_on_homepage',
field=models.BooleanField(default=False, help_text='Display this product in the homepage Products section.'),
),
]
@@ -0,0 +1,26 @@
from django.db import migrations
def copy_subproduct_homepage_flags(apps, schema_editor):
SubProduct = apps.get_model("products", "SubProduct")
for sub in (
SubProduct.objects.filter(show_on_homepage=True)
.select_related("main_product")
.order_by("homepage_order", "order")
):
main = sub.main_product
if not main.show_on_homepage:
main.show_on_homepage = True
main.homepage_order = sub.homepage_order
main.save(update_fields=["show_on_homepage", "homepage_order"])
class Migration(migrations.Migration):
dependencies = [
("products", "0011_mainproduct_homepage_order_and_more"),
]
operations = [
migrations.RunPython(copy_subproduct_homepage_flags, migrations.RunPython.noop),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.0.2 on 2026-05-31 09:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0012_migrate_homepage_visibility_to_mainproduct'),
]
operations = [
migrations.AddField(
model_name='mainproduct',
name='package_resource_button_text',
field=models.CharField(default='PyPI', help_text='Label for the package resource button on package releases.', max_length=100),
),
migrations.AddField(
model_name='mainproduct',
name='package_source_button_text',
field=models.CharField(default='Source', help_text='Label for the source code button on package releases.', max_length=100),
),
migrations.AddField(
model_name='subproduct',
name='package_resource_button_text',
field=models.CharField(default='PyPI', help_text='Label for the package resource button on package releases.', max_length=100),
),
migrations.AddField(
model_name='subproduct',
name='package_source_button_text',
field=models.CharField(default='Source', help_text='Label for the source code button on package releases.', max_length=100),
),
]
@@ -0,0 +1,34 @@
# Generated by Django 5.0.2 on 2026-05-31 09:42
import apps.products.release_assets
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0013_package_release_button_labels'),
]
operations = [
migrations.AddField(
model_name='subproductversion',
name='linux_download_file',
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', upload_to=apps.products.release_assets.release_file_upload_to),
),
migrations.AddField(
model_name='subproductversion',
name='macos_download_file',
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', upload_to=apps.products.release_assets.release_file_upload_to),
),
migrations.AddField(
model_name='subproductversion',
name='source_code_file',
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', upload_to=apps.products.release_assets.release_file_upload_to),
),
migrations.AddField(
model_name='subproductversion',
name='windows_download_file',
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', upload_to=apps.products.release_assets.release_file_upload_to),
),
]
@@ -0,0 +1,54 @@
# Generated by Django 5.0.2 on 2026-05-31 09:52
import apps.products.release_assets
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0014_release_download_files'),
]
operations = [
migrations.AddField(
model_name='subproductversion',
name='linux_download_filename',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='subproductversion',
name='macos_download_filename',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='subproductversion',
name='source_code_filename',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='subproductversion',
name='windows_download_filename',
field=models.CharField(blank=True, max_length=255),
),
migrations.AlterField(
model_name='subproductversion',
name='linux_download_file',
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_linux_file_upload_to),
),
migrations.AlterField(
model_name='subproductversion',
name='macos_download_file',
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_macos_file_upload_to),
),
migrations.AlterField(
model_name='subproductversion',
name='source_code_file',
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_source_file_upload_to),
),
migrations.AlterField(
model_name='subproductversion',
name='windows_download_file',
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_windows_file_upload_to),
),
]
@@ -0,0 +1,45 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("products", "0015_release_original_filenames"),
]
operations = [
migrations.AddField(
model_name="mainproduct",
name="downloads_section_link_text",
field=models.CharField(
blank=True,
help_text="Label for the optional downloads section link.",
max_length=100,
),
),
migrations.AddField(
model_name="mainproduct",
name="downloads_section_link_url",
field=models.URLField(
blank=True,
help_text="Optional link shown in the top-right corner of the downloads section.",
),
),
migrations.AddField(
model_name="subproduct",
name="downloads_section_link_text",
field=models.CharField(
blank=True,
help_text="Label for the optional downloads section link.",
max_length=100,
),
),
migrations.AddField(
model_name="subproduct",
name="downloads_section_link_url",
field=models.URLField(
blank=True,
help_text="Optional link shown in the top-right corner of the downloads section.",
),
),
]
@@ -0,0 +1,34 @@
# Generated by Django 5.2.13 on 2026-06-07 13:49
import apps.products.release_assets
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0016_downloads_section_link'),
]
operations = [
migrations.AlterField(
model_name='subproductversion',
name='linux_download_file',
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. To remove an uploaded file, check Clear, then Save — the file is deleted from the server. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_linux_file_upload_to),
),
migrations.AlterField(
model_name='subproductversion',
name='macos_download_file',
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. To remove an uploaded file, check Clear, then Save — the file is deleted from the server. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_macos_file_upload_to),
),
migrations.AlterField(
model_name='subproductversion',
name='source_code_file',
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. To remove an uploaded file, check Clear, then Save — the file is deleted from the server. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_source_file_upload_to),
),
migrations.AlterField(
model_name='subproductversion',
name='windows_download_file',
field=models.FileField(blank=True, help_text='Optional. Served from this site when the matching URL above is empty. To remove an uploaded file, check Clear, then Save — the file is deleted from the server. Any file type and size are allowed; large uploads may require web server limits.', storage=apps.products.release_assets.ReleaseFileStorage(), upload_to=apps.products.release_assets.release_windows_file_upload_to),
),
]
@@ -0,0 +1,39 @@
# Generated by Django 5.2.13 on 2026-06-08 12:42
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0017_alter_subproductversion_linux_download_file_and_more'),
]
operations = [
migrations.CreateModel(
name='ProductVideo',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('video_source', models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20)),
('video_url', models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500)),
('video_file', models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/')),
('video_poster', models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/')),
('video_size', models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10)),
('video_aspect_ratio', models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10)),
('badge', models.CharField(blank=True, max_length=100)),
('title', models.CharField(blank=True, max_length=300)),
('description', models.TextField(blank=True)),
('order', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
('main_product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='videos', to='products.mainproduct')),
('sub_product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='videos', to='products.subproduct')),
],
options={
'verbose_name': 'Product Video',
'verbose_name_plural': 'Product Videos',
'ordering': ['order', 'pk'],
'constraints': [models.CheckConstraint(condition=models.Q(models.Q(('main_product__isnull', True), ('sub_product__isnull', False)), models.Q(('main_product__isnull', False), ('sub_product__isnull', True)), _connector='OR'), name='product_video_exactly_one_parent')],
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.13 on 2026-06-08 13:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0018_video_support'),
]
operations = [
migrations.AddField(
model_name='productvideo',
name='video_styled_background',
field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.13 on 2026-06-08 13:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0019_video_styled_background'),
]
operations = [
migrations.AddField(
model_name='productvideo',
name='description_format',
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
),
]
@@ -0,0 +1,70 @@
from django.db import migrations, models
def migrate_featured_stable_to_channels(apps, schema_editor):
Version = apps.get_model("products", "SubProductVersion")
for version in Version.objects.filter(is_featured_stable=True):
version.is_featured = True
if not version.release_channel:
version.release_channel = "stable"
version.save(update_fields=["is_featured", "release_channel"])
class Migration(migrations.Migration):
dependencies = [
("products", "0020_video_description_format"),
]
operations = [
migrations.AddField(
model_name="subproductversion",
name="channel_label",
field=models.CharField(
blank=True,
help_text="Optional custom badge text. Overrides the preset channel label when set.",
max_length=50,
),
),
migrations.AddField(
model_name="subproductversion",
name="is_featured",
field=models.BooleanField(
default=False,
help_text="Primary release shown at the top of the downloads section.",
),
),
migrations.AddField(
model_name="subproductversion",
name="release_channel",
field=models.CharField(
blank=True,
choices=[
("", "None"),
("stable", "Stable"),
("beta", "Beta"),
("rc", "Release candidate"),
("preview", "Preview"),
("nightly", "Nightly"),
("current", "Current"),
("previous", "Previous"),
],
default="",
help_text="Preset badge for this release (Stable, Beta, Previous, etc.).",
max_length=20,
),
),
migrations.AddField(
model_name="subproductversion",
name="show_on_product_page",
field=models.BooleanField(
default=False,
help_text="Also show this release on the product page (e.g. beta or previous version).",
),
),
migrations.RunPython(migrate_featured_stable_to_channels, migrations.RunPython.noop),
migrations.RemoveField(
model_name="subproductversion",
name="is_featured_stable",
),
]
+269 -9
View File
@@ -1,9 +1,26 @@
import os
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.text import slugify from django.utils.text import slugify
from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_PLAIN, render_content from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_PLAIN, render_content
from apps.core.video import VideoBlockMixin
from .release_assets import (
FILE_FIELD_BY_ASSET_KEY,
RELEASE_ASSET_KEY_BY_URL_FIELD,
RELEASE_FILE_BY_URL_FIELD,
RELEASE_FILE_HELP_TEXT,
RELEASE_ORIGINAL_FILENAME_BY_ASSET,
RELEASE_ORIGINAL_FILENAME_FIELDS,
release_file_storage,
release_linux_file_upload_to,
release_macos_file_upload_to,
release_source_file_upload_to,
release_windows_file_upload_to,
)
INSTALLABLE_PLATFORM_SPECS = ( INSTALLABLE_PLATFORM_SPECS = (
@@ -20,6 +37,35 @@ DISTRIBUTION_CHOICES = [
(DISTRIBUTION_PACKAGE, "Package (external / non-installable)"), (DISTRIBUTION_PACKAGE, "Package (external / non-installable)"),
] ]
RELEASE_CHANNEL_STABLE = "stable"
RELEASE_CHANNEL_BETA = "beta"
RELEASE_CHANNEL_RC = "rc"
RELEASE_CHANNEL_PREVIEW = "preview"
RELEASE_CHANNEL_NIGHTLY = "nightly"
RELEASE_CHANNEL_CURRENT = "current"
RELEASE_CHANNEL_PREVIOUS = "previous"
RELEASE_CHANNEL_CHOICES = [
("", "None"),
(RELEASE_CHANNEL_STABLE, "Stable"),
(RELEASE_CHANNEL_BETA, "Beta"),
(RELEASE_CHANNEL_RC, "Release candidate"),
(RELEASE_CHANNEL_PREVIEW, "Preview"),
(RELEASE_CHANNEL_NIGHTLY, "Nightly"),
(RELEASE_CHANNEL_CURRENT, "Current"),
(RELEASE_CHANNEL_PREVIOUS, "Previous"),
]
RELEASE_CHANNEL_LABELS = {
RELEASE_CHANNEL_STABLE: "Stable",
RELEASE_CHANNEL_BETA: "Beta",
RELEASE_CHANNEL_RC: "Release candidate",
RELEASE_CHANNEL_PREVIEW: "Preview",
RELEASE_CHANNEL_NIGHTLY: "Nightly",
RELEASE_CHANNEL_CURRENT: "Current",
RELEASE_CHANNEL_PREVIOUS: "Previous",
}
class MainProduct(models.Model): class MainProduct(models.Model):
name = models.CharField(max_length=200) name = models.CharField(max_length=200)
@@ -36,8 +82,29 @@ class MainProduct(models.Model):
default=DISTRIBUTION_INSTALLABLE, default=DISTRIBUTION_INSTALLABLE,
help_text="Only affects how releases appear on the public site.", 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) order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True) 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.")
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -97,6 +164,25 @@ class SubProduct(models.Model):
default=DISTRIBUTION_INSTALLABLE, default=DISTRIBUTION_INSTALLABLE,
help_text="Only affects how releases appear on the public site.", 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.",
)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -224,14 +310,58 @@ class SubProductVersion(models.Model):
blank=True, blank=True,
) )
version = models.CharField(max_length=80) version = models.CharField(max_length=80)
is_featured_stable = models.BooleanField( release_channel = models.CharField(
max_length=20,
choices=RELEASE_CHANNEL_CHOICES,
blank=True,
default="",
help_text="Preset badge for this release (Stable, Beta, Previous, etc.).",
)
channel_label = models.CharField(
max_length=50,
blank=True,
help_text="Optional custom badge text. Overrides the preset channel label when set.",
)
is_featured = models.BooleanField(
default=False, default=False,
help_text="Highlighted as the main release on the product page.", help_text="Primary release shown at the top of the downloads section.",
)
show_on_product_page = models.BooleanField(
default=False,
help_text="Also show this release on the product page (e.g. beta or previous version).",
) )
windows_download_url = models.URLField(blank=True) windows_download_url = models.URLField(blank=True)
macos_download_url = models.URLField(blank=True) macos_download_url = models.URLField(blank=True)
linux_download_url = models.URLField(blank=True) linux_download_url = models.URLField(blank=True)
source_code_url = models.URLField(blank=True) source_code_url = models.URLField(blank=True)
windows_download_file = models.FileField(
upload_to=release_windows_file_upload_to,
storage=release_file_storage,
blank=True,
help_text=RELEASE_FILE_HELP_TEXT,
)
macos_download_file = models.FileField(
upload_to=release_macos_file_upload_to,
storage=release_file_storage,
blank=True,
help_text=RELEASE_FILE_HELP_TEXT,
)
linux_download_file = models.FileField(
upload_to=release_linux_file_upload_to,
storage=release_file_storage,
blank=True,
help_text=RELEASE_FILE_HELP_TEXT,
)
source_code_file = models.FileField(
upload_to=release_source_file_upload_to,
storage=release_file_storage,
blank=True,
help_text=RELEASE_FILE_HELP_TEXT,
)
windows_download_filename = models.CharField(max_length=255, blank=True)
macos_download_filename = models.CharField(max_length=255, blank=True)
linux_download_filename = models.CharField(max_length=255, blank=True)
source_code_filename = models.CharField(max_length=255, blank=True)
package_resource_url = models.URLField( package_resource_url = models.URLField(
blank=True, blank=True,
help_text="Shown on the site when set. For Resources it is the main Open link; for Installable it appears under the platform grid.", help_text="Shown on the site when set. For Resources it is the main Open link; for Installable it appears under the platform grid.",
@@ -277,19 +407,98 @@ class SubProductVersion(models.Model):
parent = self.sub_product or self.main_product parent = self.sub_product or self.main_product
return f"{parent.name} v{self.version}" return f"{parent.name} v{self.version}"
@property
def display_channel_label(self):
custom = (self.channel_label or "").strip()
if custom:
return custom
if self.release_channel:
return RELEASE_CHANNEL_LABELS.get(
self.release_channel,
self.release_channel.replace("_", " ").title(),
)
return ""
@property
def display_channel_css_modifier(self):
if (self.channel_label or "").strip():
return "custom"
return self.release_channel or "none"
@property
def is_previous_channel(self):
if self.release_channel == RELEASE_CHANNEL_PREVIOUS:
return True
label = (self.channel_label or "").strip().lower()
return label == "previous"
def save(self, *args, **kwargs):
for file_field, name_field in RELEASE_ORIGINAL_FILENAME_FIELDS.items():
field_file = getattr(self, file_field)
if field_file and field_file._file is not None:
setattr(self, name_field, os.path.basename(field_file.name))
elif not field_file:
setattr(self, name_field, "")
super().save(*args, **kwargs)
def release_download_filename(self, asset):
name_field = RELEASE_ORIGINAL_FILENAME_BY_ASSET.get(asset)
if name_field:
stored = (getattr(self, name_field, "") or "").strip()
if stored:
return stored
file_field = FILE_FIELD_BY_ASSET_KEY.get(asset)
if file_field:
release_file = getattr(self, file_field)
if release_file:
return os.path.basename(release_file.name)
return "download"
def has_platform_asset(self, url_field_name):
if (getattr(self, url_field_name) or "").strip():
return True
file_field = RELEASE_FILE_BY_URL_FIELD.get(url_field_name)
if not file_field:
return False
return bool(getattr(self, file_field))
def resolve_asset_url(self, url_field_name):
url = (getattr(self, url_field_name) or "").strip()
if url:
return url
file_field = RELEASE_FILE_BY_URL_FIELD.get(url_field_name)
if not file_field or not getattr(self, file_field):
return ""
if not self.pk:
return ""
asset = RELEASE_ASSET_KEY_BY_URL_FIELD[url_field_name]
return reverse(
"products:release_asset_download",
kwargs={"version_id": self.pk, "asset": asset},
)
def install_urls_for_specs(self, specs): def install_urls_for_specs(self, specs):
out = [] out = []
for field_name, label, icon in specs: for field_name, label, icon in specs:
url = getattr(self, field_name, "") or "" url = self.resolve_asset_url(field_name)
if url.strip(): if url:
out.append({"field": field_name, "label": label, "icon": icon, "url": url}) out.append(
{
"field": field_name,
"label": label,
"icon": icon,
"url": url,
"external": bool((getattr(self, field_name) or "").strip()),
}
)
return out return out
def has_any_install_asset(self): def has_any_install_asset(self):
return any( return any(self.has_platform_asset(f[0]) for f in INSTALLABLE_PLATFORM_SPECS)
(getattr(self, f[0]) or "").strip()
for f in INSTALLABLE_PLATFORM_SPECS @property
) def resolved_source_code_url(self):
return self.resolve_asset_url("source_code_url")
def has_package_link(self): def has_package_link(self):
return bool((self.package_resource_url or "").strip()) return bool((self.package_resource_url or "").strip())
@@ -338,3 +547,54 @@ class ArticleCitation(models.Model):
def __str__(self): def __str__(self):
return f"{self.article.title} — citation {self.order or self.pk}" return f"{self.article.title} — citation {self.order or self.pk}"
class ProductVideo(VideoBlockMixin, models.Model):
main_product = models.ForeignKey(
MainProduct,
on_delete=models.CASCADE,
related_name="videos",
null=True,
blank=True,
)
sub_product = models.ForeignKey(
SubProduct,
on_delete=models.CASCADE,
related_name="videos",
null=True,
blank=True,
)
badge = models.CharField(max_length=100, blank=True)
title = models.CharField(max_length=300, blank=True)
description = models.TextField(blank=True)
order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ["order", "pk"]
verbose_name = "Product Video"
verbose_name_plural = "Product Videos"
constraints = [
models.CheckConstraint(
check=(
models.Q(sub_product__isnull=False, main_product__isnull=True)
| models.Q(sub_product__isnull=True, main_product__isnull=False)
),
name="product_video_exactly_one_parent",
),
]
def clean(self):
super().clean()
has_main = self.main_product_id is not None
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."
)
self.clean_video_fields(require=True)
def __str__(self):
parent = self.sub_product or self.main_product
label = self.title or self.badge or "Video"
return f"{parent} {label}"
+88
View File
@@ -0,0 +1,88 @@
import os
from django.core.files.storage import FileSystemStorage
from django.utils.text import get_valid_filename
class ReleaseFileStorage(FileSystemStorage):
def __init__(self, **kwargs):
kwargs.setdefault("allow_overwrite", True)
super().__init__(**kwargs)
def get_available_name(self, name, max_length=None):
return name
release_file_storage = ReleaseFileStorage()
RELEASE_FILE_BY_URL_FIELD = {
"windows_download_url": "windows_download_file",
"macos_download_url": "macos_download_file",
"linux_download_url": "linux_download_file",
"source_code_url": "source_code_file",
}
RELEASE_ASSET_KEY_BY_URL_FIELD = {
"windows_download_url": "windows",
"macos_download_url": "macos",
"linux_download_url": "linux",
"source_code_url": "source",
}
URL_FIELD_BY_ASSET_KEY = {
v: k for k, v in RELEASE_ASSET_KEY_BY_URL_FIELD.items()
}
FILE_FIELD_BY_ASSET_KEY = {
asset: RELEASE_FILE_BY_URL_FIELD[url_field]
for url_field, asset in RELEASE_ASSET_KEY_BY_URL_FIELD.items()
}
RELEASE_ASSET_KEYS = frozenset(FILE_FIELD_BY_ASSET_KEY.keys())
RELEASE_FILE_HELP_TEXT = (
"Optional. Served from this site when the matching URL above is empty. "
"To remove an uploaded file, check Clear, then Save — the file is deleted from the server. "
"Any file type and size are allowed; large uploads may require web server limits."
)
RELEASE_ORIGINAL_FILENAME_FIELDS = {
"windows_download_file": "windows_download_filename",
"macos_download_file": "macos_download_filename",
"linux_download_file": "linux_download_filename",
"source_code_file": "source_code_filename",
}
RELEASE_ORIGINAL_FILENAME_BY_ASSET = {
RELEASE_ASSET_KEY_BY_URL_FIELD[url_field]: RELEASE_ORIGINAL_FILENAME_FIELDS[file_field]
for url_field, file_field in RELEASE_FILE_BY_URL_FIELD.items()
}
def _release_file_path(instance, filename, platform):
if instance.sub_product_id:
base = f"{instance.sub_product.main_product.slug}/{instance.sub_product.slug}"
else:
base = instance.main_product.slug
version_part = (instance.version or "release").replace("/", "-")
safe_name = get_valid_filename(os.path.basename(filename))
return f"releases/{base}/{version_part}/{platform}/{safe_name}"
def release_windows_file_upload_to(instance, filename):
return _release_file_path(instance, filename, "windows")
def release_macos_file_upload_to(instance, filename):
return _release_file_path(instance, filename, "macos")
def release_linux_file_upload_to(instance, filename):
return _release_file_path(instance, filename, "linux")
def release_source_file_upload_to(instance, filename):
return _release_file_path(instance, filename, "source")
release_file_upload_to = release_windows_file_upload_to
+88 -27
View File
@@ -1,58 +1,106 @@
from .models import DISTRIBUTION_INSTALLABLE, DISTRIBUTION_PACKAGE, INSTALLABLE_PLATFORM_SPECS from .models import DISTRIBUTION_INSTALLABLE, DISTRIBUTION_PACKAGE, INSTALLABLE_PLATFORM_SPECS
DEFAULT_PACKAGE_RESOURCE_BUTTON_TEXT = "PyPI"
DEFAULT_PACKAGE_SOURCE_BUTTON_TEXT = "Source"
def package_button_labels(product):
resource = (getattr(product, "package_resource_button_text", "") or "").strip()
source = (getattr(product, "package_source_button_text", "") or "").strip()
return {
"package_resource_button_text": resource or DEFAULT_PACKAGE_RESOURCE_BUTTON_TEXT,
"package_source_button_text": source or DEFAULT_PACKAGE_SOURCE_BUTTON_TEXT,
}
def downloads_section_link(product):
url = (getattr(product, "downloads_section_link_url", "") or "").strip()
text = (getattr(product, "downloads_section_link_text", "") or "").strip()
if url and text:
return {"url": url, "text": text}
return None
def featured_version(qs): def featured_version(qs):
ordered = qs.order_by("order", "pk") ordered = qs.order_by("order", "pk")
cand = ordered.filter(is_featured_stable=True).first() cand = ordered.filter(is_featured=True).first()
return cand if cand else ordered.first() return cand if cand else ordered.first()
def version_has_public_assets(version):
return version.has_any_install_asset() or bool((version.package_resource_url or "").strip())
def install_specs_from_versions(version_list): def install_specs_from_versions(version_list):
present = set() present = set()
for ver in version_list: for ver in version_list:
for field_name, *_ in INSTALLABLE_PLATFORM_SPECS: for field_name, *_ in INSTALLABLE_PLATFORM_SPECS:
if (getattr(ver, field_name) or "").strip(): if ver.has_platform_asset(field_name):
present.add(field_name) present.add(field_name)
return tuple(s for s in INSTALLABLE_PLATFORM_SPECS if s[0] in present) return tuple(s for s in INSTALLABLE_PLATFORM_SPECS if s[0] in present)
def build_release_context(distribution, active_versions): def build_installable_channel_block(version):
if not version_has_public_assets(version):
return None
block = {
"version": version,
"install_cells": [],
"is_previous": version.is_previous_channel,
}
if version.has_any_install_asset():
specs = install_specs_from_versions([version])
block["install_cells"] = version.install_urls_for_specs(specs)
return block
def build_release_context(distribution, active_versions, product):
context = { context = {
"distribution_installable": distribution == DISTRIBUTION_INSTALLABLE, "distribution_installable": distribution == DISTRIBUTION_INSTALLABLE,
"distribution_package": distribution == DISTRIBUTION_PACKAGE, "distribution_package": distribution == DISTRIBUTION_PACKAGE,
"show_releases_section": False, "show_releases_section": False,
"featured_version": None, "featured_version": None,
"featured_channel": None,
"featured_install_cells": [], "featured_install_cells": [],
"inline_download_channels": [],
"show_older_versions_link": False, "show_older_versions_link": False,
"package_versions": [], "package_versions": [],
**package_button_labels(product),
"downloads_section_link": downloads_section_link(product),
} }
if distribution == DISTRIBUTION_INSTALLABLE: if distribution == DISTRIBUTION_INSTALLABLE:
featured = featured_version(active_versions) featured = featured_version(active_versions)
if ( if featured and not version_has_public_assets(featured):
featured
and not featured.has_any_install_asset()
and not (featured.package_resource_url or "").strip()
):
for cand in active_versions.exclude(pk=featured.pk).order_by("order", "pk"): for cand in active_versions.exclude(pk=featured.pk).order_by("order", "pk"):
if cand.has_any_install_asset() or (cand.package_resource_url or "").strip(): if version_has_public_assets(cand):
featured = cand featured = cand
break break
context["featured_version"] = featured context["featured_version"] = featured
if featured and ( if featured and version_has_public_assets(featured):
featured.has_any_install_asset() featured_block = build_installable_channel_block(featured)
or (featured.package_resource_url or "").strip() if featured_block:
): context["featured_channel"] = featured_block
if featured.has_any_install_asset(): context["featured_install_cells"] = featured_block["install_cells"]
specs = install_specs_from_versions([featured])
context["featured_install_cells"] = featured.install_urls_for_specs(specs)
context["show_releases_section"] = True context["show_releases_section"] = True
older_list = list(
active_versions.exclude(pk=featured.pk).order_by("order", "pk") inline_blocks = []
if featured for ver in active_versions.order_by("order", "pk"):
else active_versions.order_by("order", "pk") if featured and ver.pk == featured.pk:
) continue
context["show_older_versions_link"] = len(older_list) > 0 if not ver.show_on_product_page:
continue
block = build_installable_channel_block(ver)
if block:
inline_blocks.append(block)
context["inline_download_channels"] = inline_blocks
shown_pks = {featured.pk} if featured else set()
shown_pks.update(block["version"].pk for block in inline_blocks)
older_qs = active_versions.order_by("order", "pk")
if shown_pks:
older_qs = older_qs.exclude(pk__in=shown_pks)
context["show_older_versions_link"] = older_qs.exists()
elif distribution == DISTRIBUTION_PACKAGE: elif distribution == DISTRIBUTION_PACKAGE:
pkg_versions = list(active_versions.order_by("order", "pk")) pkg_versions = list(active_versions.order_by("order", "pk"))
@@ -62,16 +110,24 @@ def build_release_context(distribution, active_versions):
return context return context
def build_archive_context(distribution, active_versions): def build_archive_context(distribution, active_versions, product):
featured = featured_version(active_versions) featured = featured_version(active_versions)
inline_pks = set(
active_versions.filter(show_on_product_page=True).values_list("pk", flat=True)
)
exclude_pks = set()
if featured:
exclude_pks.add(featured.pk)
exclude_pks.update(inline_pks)
archive = list( archive = list(
active_versions.exclude(pk=featured.pk).order_by("order", "pk") active_versions.exclude(pk__in=exclude_pks).order_by("order", "pk")
if featured if exclude_pks
else active_versions.order_by("order", "pk") else active_versions.order_by("order", "pk")
) )
context = { context = {
"featured_version": featured, "featured_version": featured,
"archive_versions": archive, "archive_versions": archive,
**package_button_labels(product),
} }
if distribution == DISTRIBUTION_INSTALLABLE: if distribution == DISTRIBUTION_INSTALLABLE:
@@ -81,9 +137,14 @@ def build_archive_context(distribution, active_versions):
for ver in archive: for ver in archive:
cells = [] cells = []
for field_name, label, icon in specs: for field_name, label, icon in specs:
u = (getattr(ver, field_name) or "").strip()
cells.append( cells.append(
{"field": field_name, "label": label, "icon": icon, "url": u} {
"field": field_name,
"label": label,
"icon": icon,
"url": ver.resolve_asset_url(field_name),
"external": bool((getattr(ver, field_name) or "").strip()),
}
) )
archive_rows_installable.append({"version_obj": ver, "cells": cells}) archive_rows_installable.append({"version_obj": ver, "cells": cells})
context["archive_rows_installable"] = archive_rows_installable context["archive_rows_installable"] = archive_rows_installable
+37
View File
@@ -0,0 +1,37 @@
from django.db.models.signals import post_delete, pre_save
from django.dispatch import receiver
from .models import SubProductVersion
from .release_assets import RELEASE_FILE_BY_URL_FIELD
def _stored_release_file_name(version, file_field):
release_file = getattr(version, file_field)
return release_file.name if release_file else ""
def _delete_release_file_from_disk(version, file_field):
release_file = getattr(version, file_field)
if release_file:
release_file.delete(save=False)
@receiver(pre_save, sender=SubProductVersion)
def delete_replaced_or_cleared_release_files(sender, instance, **kwargs):
if not instance.pk:
return
try:
previous = SubProductVersion.objects.get(pk=instance.pk)
except SubProductVersion.DoesNotExist:
return
for file_field in RELEASE_FILE_BY_URL_FIELD.values():
old_name = _stored_release_file_name(previous, file_field)
new_name = _stored_release_file_name(instance, file_field)
if old_name and old_name != new_name:
_delete_release_file_from_disk(previous, file_field)
@receiver(post_delete, sender=SubProductVersion)
def delete_release_files_on_version_delete(sender, instance, **kwargs):
for file_field in RELEASE_FILE_BY_URL_FIELD.values():
_delete_release_file_from_disk(instance, file_field)
+50
View File
@@ -155,6 +155,33 @@ class ArticleModelTest(TestCase):
article.full_clean() article.full_clean()
class ReleaseFileStorageTest(TestCase):
def test_save_overwrites_existing_release_file(self):
from django.core.files.uploadedfile import SimpleUploadedFile
from apps.products.models import MainProduct, SubProductVersion
main = MainProduct.objects.create(
name="Storage Test",
short_description="s",
description="d",
)
version = SubProductVersion.objects.create(
main_product=main,
version="1.0",
windows_download_file=SimpleUploadedFile("setup.exe", b"v1"),
is_active=True,
)
version.windows_download_file.save(
"setup.exe",
SimpleUploadedFile("setup.exe", b"v2"),
save=True,
)
version.refresh_from_db()
with version.windows_download_file.open("rb") as handle:
self.assertEqual(handle.read(), b"v2")
class SubProductVersionModelTest(TestCase): class SubProductVersionModelTest(TestCase):
def setUp(self): def setUp(self):
self.main_product = MainProduct.objects.create( self.main_product = MainProduct.objects.create(
@@ -186,6 +213,29 @@ class SubProductVersionModelTest(TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
version.full_clean() version.full_clean()
def test_display_channel_label_uses_custom_text(self):
from apps.products.models import RELEASE_CHANNEL_BETA
version = SubProductVersion.objects.create(
main_product=self.main_product,
version="2.0",
release_channel=RELEASE_CHANNEL_BETA,
channel_label="Preview build",
)
self.assertEqual(version.display_channel_label, "Preview build")
self.assertEqual(version.display_channel_css_modifier, "custom")
def test_display_channel_label_uses_preset(self):
from apps.products.models import RELEASE_CHANNEL_PREVIOUS
version = SubProductVersion.objects.create(
sub_product=self.sub_product,
version="1.0",
release_channel=RELEASE_CHANNEL_PREVIOUS,
)
self.assertEqual(version.display_channel_label, "Previous")
self.assertTrue(version.is_previous_channel)
class ArticleSectionModelTest(TestCase): class ArticleSectionModelTest(TestCase):
def setUp(self): def setUp(self):
+158 -20
View File
@@ -1,14 +1,18 @@
from django.test import TestCase import os
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct from apps.products.models import Article, ArticleSection, MainProduct, SubProduct, SubProductVersion
class ProductViewsSetup(TestCase): class ProductViewsSetup(TestCase):
def setUp(self): def setUp(self):
self.main_product = MainProduct.objects.create( self.main_product = MainProduct.objects.create(
name="Radiuma", name="Communication to Care",
slug="radiuma", slug="com2care",
short_description="Radiomics software", short_description="Radiomics software",
description="Full description.", description="Full description.",
) )
@@ -57,17 +61,17 @@ class ProductOverviewViewTest(ProductViewsSetup):
class MainProductDetailViewTest(ProductViewsSetup): class MainProductDetailViewTest(ProductViewsSetup):
def test_detail_returns_200(self): def test_detail_returns_200(self):
url = reverse("products:main_product_detail", kwargs={"main_slug": "radiuma"}) url = reverse("products:main_product_detail", kwargs={"main_slug": "com2care"})
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_detail_uses_correct_template(self): def test_detail_uses_correct_template(self):
url = reverse("products:main_product_detail", kwargs={"main_slug": "radiuma"}) url = reverse("products:main_product_detail", kwargs={"main_slug": "com2care"})
response = self.client.get(url) response = self.client.get(url)
self.assertTemplateUsed(response, "products/main_detail.html") self.assertTemplateUsed(response, "products/main_detail.html")
def test_detail_contains_product_in_context(self): def test_detail_contains_product_in_context(self):
url = reverse("products:main_product_detail", kwargs={"main_slug": "radiuma"}) url = reverse("products:main_product_detail", kwargs={"main_slug": "com2care"})
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.context["main_product"], self.main_product) self.assertEqual(response.context["main_product"], self.main_product)
@@ -79,7 +83,7 @@ class MainProductDetailViewTest(ProductViewsSetup):
def test_inactive_product_returns_404(self): def test_inactive_product_returns_404(self):
self.main_product.is_active = False self.main_product.is_active = False
self.main_product.save() self.main_product.save()
url = reverse("products:main_product_detail", kwargs={"main_slug": "radiuma"}) url = reverse("products:main_product_detail", kwargs={"main_slug": "com2care"})
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
@@ -89,7 +93,7 @@ class MainProductDetailViewTest(ProductViewsSetup):
title="Overview", title="Overview",
description="Main product article.", description="Main product article.",
) )
url = reverse("products:main_product_detail", kwargs={"main_slug": "radiuma"}) url = reverse("products:main_product_detail", kwargs={"main_slug": "com2care"})
response = self.client.get(url) response = self.client.get(url)
self.assertIn("articles", response.context) self.assertIn("articles", response.context)
self.assertIn(main_article, list(response.context["articles"])) self.assertIn(main_article, list(response.context["articles"]))
@@ -100,7 +104,7 @@ class SubProductDetailViewTest(ProductViewsSetup):
def test_sub_detail_returns_200(self): def test_sub_detail_returns_200(self):
url = reverse( url = reverse(
"products:sub_product_detail", "products:sub_product_detail",
kwargs={"main_slug": "radiuma", "sub_slug": "image-processing"}, kwargs={"main_slug": "com2care", "sub_slug": "image-processing"},
) )
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@@ -108,7 +112,7 @@ class SubProductDetailViewTest(ProductViewsSetup):
def test_sub_detail_uses_correct_template(self): def test_sub_detail_uses_correct_template(self):
url = reverse( url = reverse(
"products:sub_product_detail", "products:sub_product_detail",
kwargs={"main_slug": "radiuma", "sub_slug": "image-processing"}, kwargs={"main_slug": "com2care", "sub_slug": "image-processing"},
) )
response = self.client.get(url) response = self.client.get(url)
self.assertTemplateUsed(response, "products/sub_detail.html") self.assertTemplateUsed(response, "products/sub_detail.html")
@@ -116,7 +120,7 @@ class SubProductDetailViewTest(ProductViewsSetup):
def test_sub_detail_context_keys(self): def test_sub_detail_context_keys(self):
url = reverse( url = reverse(
"products:sub_product_detail", "products:sub_product_detail",
kwargs={"main_slug": "radiuma", "sub_slug": "image-processing"}, kwargs={"main_slug": "com2care", "sub_slug": "image-processing"},
) )
response = self.client.get(url) response = self.client.get(url)
self.assertIn("main_product", response.context) self.assertIn("main_product", response.context)
@@ -127,7 +131,7 @@ class SubProductDetailViewTest(ProductViewsSetup):
def test_sub_detail_articles_in_context(self): def test_sub_detail_articles_in_context(self):
url = reverse( url = reverse(
"products:sub_product_detail", "products:sub_product_detail",
kwargs={"main_slug": "radiuma", "sub_slug": "image-processing"}, kwargs={"main_slug": "com2care", "sub_slug": "image-processing"},
) )
response = self.client.get(url) response = self.client.get(url)
self.assertIn(self.article, list(response.context["articles"])) self.assertIn(self.article, list(response.context["articles"]))
@@ -135,7 +139,7 @@ class SubProductDetailViewTest(ProductViewsSetup):
def test_nonexistent_sub_slug_returns_404(self): def test_nonexistent_sub_slug_returns_404(self):
url = reverse( url = reverse(
"products:sub_product_detail", "products:sub_product_detail",
kwargs={"main_slug": "radiuma", "sub_slug": "does-not-exist"}, kwargs={"main_slug": "com2care", "sub_slug": "does-not-exist"},
) )
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
@@ -156,20 +160,21 @@ class SubProductOlderVersionsViewTest(ProductViewsSetup):
SubProductVersion.objects.create( SubProductVersion.objects.create(
sub_product=self.sub_product, sub_product=self.sub_product,
version="9.9", version="9.9",
is_featured_stable=True, is_featured=True,
release_channel="stable",
windows_download_url="https://example.com/w", windows_download_url="https://example.com/w",
is_active=True, is_active=True,
) )
SubProductVersion.objects.create( SubProductVersion.objects.create(
sub_product=self.sub_product, sub_product=self.sub_product,
version="9.8", version="9.8",
is_featured_stable=False, is_featured=False,
windows_download_url="https://example.com/w2", windows_download_url="https://example.com/w2",
is_active=True, is_active=True,
) )
url = reverse( url = reverse(
"products:sub_product_versions", "products:sub_product_versions",
kwargs={"main_slug": "radiuma", "sub_slug": "image-processing"}, kwargs={"main_slug": "com2care", "sub_slug": "image-processing"},
) )
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@@ -181,7 +186,8 @@ class SubProductOlderVersionsViewTest(ProductViewsSetup):
SubProductVersion.objects.create( SubProductVersion.objects.create(
main_product=self.main_product, main_product=self.main_product,
version="9.9", version="9.9",
is_featured_stable=True, is_featured=True,
release_channel="stable",
windows_download_url="https://example.com/win.exe", windows_download_url="https://example.com/win.exe",
is_active=True, is_active=True,
) )
@@ -192,7 +198,7 @@ class SubProductOlderVersionsViewTest(ProductViewsSetup):
) )
url = reverse( url = reverse(
"products:main_product_versions", "products:main_product_versions",
kwargs={"main_slug": "radiuma"}, kwargs={"main_slug": "com2care"},
) )
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@@ -207,6 +213,138 @@ class SubProductOlderVersionsViewTest(ProductViewsSetup):
windows_download_url="https://example.com/win.exe", windows_download_url="https://example.com/win.exe",
is_active=True, is_active=True,
) )
url = reverse("products:main_product_detail", kwargs={"main_slug": "radiuma"}) url = reverse("products:main_product_detail", kwargs={"main_slug": "com2care"})
response = self.client.get(url) response = self.client.get(url)
self.assertTrue(response.context["show_releases_section"]) 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))
+15
View File
@@ -6,6 +6,21 @@ app_name = "products"
urlpatterns = [ urlpatterns = [
path("", views.ProductOverviewView.as_view(), name="overview"), 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(),
name="main_product_videos",
),
path(
"<slug:main_slug>/<slug:sub_slug>/videos/",
views.ProductVideoArchiveView.as_view(),
name="sub_product_videos",
),
path( path(
"<slug:main_slug>/", "<slug:main_slug>/",
views.MainProductDetailView.as_view(), views.MainProductDetailView.as_view(),
+119 -4
View File
@@ -1,9 +1,52 @@
import mimetypes
from django.http import FileResponse, Http404
from django.shortcuts import get_object_or_404 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 django.views.generic import DetailView, ListView, TemplateView
from .models import MainProduct, SubProduct 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 .release_context import build_archive_context, build_release_context
VIDEO_PREVIEW_LIMIT = 3
VIDEO_ARCHIVE_PAGE_SIZE = 6
def _product_video_preview(queryset, archive_url):
total = queryset.count()
return {
"product_videos": queryset[:VIDEO_PREVIEW_LIMIT],
"videos_total_count": total,
"videos_archive_url": archive_url if total > VIDEO_PREVIEW_LIMIT else "",
}
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): class ProductOverviewView(ListView):
model = MainProduct model = MainProduct
@@ -28,9 +71,20 @@ class MainProductDetailView(DetailView):
context["articles"] = self.object.articles.prefetch_related( context["articles"] = self.object.articles.prefetch_related(
"sections", "citations" "sections", "citations"
).all() ).all()
videos = self.object.videos.filter(is_active=True).order_by("order", "pk")
context.update(
_product_video_preview(
videos,
reverse(
"products:main_product_videos",
kwargs={"main_slug": self.object.slug},
),
)
)
release_context = build_release_context( release_context = build_release_context(
self.object.distribution, self.object.distribution,
self.object.versions.filter(is_active=True), self.object.versions.filter(is_active=True),
self.object,
) )
release_context["versions_archive_url"] = self.object.get_versions_archive_url() release_context["versions_archive_url"] = self.object.get_versions_archive_url()
context.update(release_context) context.update(release_context)
@@ -52,7 +106,9 @@ class MainProductOlderVersionsView(TemplateView):
context["sub_product"] = None context["sub_product"] = None
context["product_detail_url"] = main_product.get_absolute_url() context["product_detail_url"] = main_product.get_absolute_url()
context.update( context.update(
build_archive_context(main_product.distribution, active_versions) build_archive_context(
main_product.distribution, active_versions, main_product
)
) )
return context return context
@@ -78,6 +134,19 @@ class SubProductDetailView(TemplateView):
context["articles"] = sub_product.articles.prefetch_related( context["articles"] = sub_product.articles.prefetch_related(
"sections", "citations" "sections", "citations"
).all() ).all()
videos = sub_product.videos.filter(is_active=True).order_by("order", "pk")
context.update(
_product_video_preview(
videos,
reverse(
"products:sub_product_videos",
kwargs={
"main_slug": main_product.slug,
"sub_slug": sub_product.slug,
},
),
)
)
context["siblings"] = ( context["siblings"] = (
SubProduct.objects.filter(main_product=main_product, is_active=True) SubProduct.objects.filter(main_product=main_product, is_active=True)
.exclude(pk=sub_product.pk) .exclude(pk=sub_product.pk)
@@ -85,13 +154,57 @@ class SubProductDetailView(TemplateView):
) )
active_versions = sub_product.versions.filter(is_active=True) active_versions = sub_product.versions.filter(is_active=True)
release_context = build_release_context( release_context = build_release_context(
sub_product.distribution, active_versions sub_product.distribution, active_versions, sub_product
) )
release_context["versions_archive_url"] = sub_product.get_versions_archive_url() release_context["versions_archive_url"] = sub_product.get_versions_archive_url()
context.update(release_context) context.update(release_context)
return context return context
class ProductVideoArchiveView(ListView):
model = ProductVideo
template_name = "videos/archive.html"
context_object_name = "videos"
paginate_by = VIDEO_ARCHIVE_PAGE_SIZE
def get_parent(self):
main_product = get_object_or_404(
MainProduct,
slug=self.kwargs["main_slug"],
is_active=True,
)
sub_slug = self.kwargs.get("sub_slug")
if sub_slug:
return get_object_or_404(
SubProduct,
slug=sub_slug,
main_product=main_product,
is_active=True,
)
return main_product
def get_queryset(self):
self.parent_object = self.get_parent()
return self.parent_object.videos.filter(is_active=True).order_by("order", "pk")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(
{
"library_title": f"{self.parent_object.name} videos",
"library_description": (
"Tutorials, demonstrations, and technical guides."
),
"library_back_url": self.parent_object.get_absolute_url(),
"library_back_label": f"Back to {self.parent_object.name}",
"pagination_range": context["paginator"].get_elided_page_range(
context["page_obj"].number
),
}
)
return context
class SubProductOlderVersionsView(TemplateView): class SubProductOlderVersionsView(TemplateView):
template_name = "products/versions_archive.html" template_name = "products/versions_archive.html"
@@ -113,6 +226,8 @@ class SubProductOlderVersionsView(TemplateView):
context["sub_product"] = sub_product context["sub_product"] = sub_product
context["product_detail_url"] = sub_product.get_absolute_url() context["product_detail_url"] = sub_product.get_absolute_url()
context.update( context.update(
build_archive_context(sub_product.distribution, active_versions) build_archive_context(
sub_product.distribution, active_versions, sub_product
)
) )
return context return context
+6 -2
View File
@@ -67,8 +67,8 @@ WSGI_APPLICATION = "config.wsgi.application"
DATABASES = { DATABASES = {
"default": { "default": {
"ENGINE": "django.db.backends.postgresql", "ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("POSTGRES_DB", "tecvico"), "NAME": os.environ.get("POSTGRES_DB", "com2care"),
"USER": os.environ.get("POSTGRES_USER", "tecvico_user"), "USER": os.environ.get("POSTGRES_USER", "com2care_user"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""), "PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""),
"HOST": os.environ.get("POSTGRES_HOST", "localhost"), "HOST": os.environ.get("POSTGRES_HOST", "localhost"),
"PORT": os.environ.get("POSTGRES_PORT", "5432"), "PORT": os.environ.get("POSTGRES_PORT", "5432"),
@@ -99,6 +99,10 @@ STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
MEDIA_URL = "/media/" MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media" MEDIA_ROOT = BASE_DIR / "media"
CONTACT_UPLOAD_ROOT = BASE_DIR / "private_uploads" / "contact"
CONTACT_ATTACHMENT_MAX_SIZE = 10 * 1024 * 1024
CONTACT_ATTACHMENT_MAX_COUNT = 3
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGGING = { LOGGING = {
+3
View File
@@ -0,0 +1,3 @@
from .development import * # noqa: F401, F403
DATABASES["default"]["CONN_MAX_AGE"] = 0 # noqa: F405
+3 -3
View File
@@ -5,9 +5,9 @@ from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
admin.site.site_header = "Radiuma Administration" admin.site.site_header = "Communication to Care Administration"
admin.site.site_title = "Radiuma Admin" admin.site.site_title = "Communication to Care Admin"
admin.site.index_title = "Welcome to Radiuma Administration" admin.site.index_title = "Welcome to Communication to Care Administration"
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
+4
View File
@@ -1,4 +1,8 @@
services: services:
db:
ports:
- "5433:5432"
web: web:
build: . build: .
environment: environment:
+19 -9
View File
@@ -4,14 +4,12 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
env_file:
- .env
environment: environment:
POSTGRES_DB: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB:-com2care}
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER:-com2care_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-com2care_local_password}
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-com2care_user} -d ${POSTGRES_DB:-com2care}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -21,11 +19,22 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8000:8000" - "8000:8000"
env_file:
- .env
environment: environment:
DJANGO_SETTINGS_MODULE: config.settings.production DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-config.settings.production}
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-django-insecure-local-com2care-change-in-production}
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,com2care.com,www.com2care.com}
CSRF_TRUSTED_ORIGINS: ${CSRF_TRUSTED_ORIGINS:-http://localhost:8000,https://com2care.com,https://www.com2care.com}
SECURE_SSL_REDIRECT: ${SECURE_SSL_REDIRECT:-False}
POSTGRES_DB: ${POSTGRES_DB:-com2care}
POSTGRES_USER: ${POSTGRES_USER:-com2care_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-com2care_local_password}
POSTGRES_HOST: db POSTGRES_HOST: db
POSTGRES_PORT: ${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}
DJANGO_SUPERUSER_PASSWORD: ${DJANGO_SUPERUSER_PASSWORD:-cmosV6Tw46Odv7UN}
DJANGO_SUPERUSER_SYNC_PASSWORD: ${DJANGO_SUPERUSER_SYNC_PASSWORD:-False}
volumes: volumes:
- ./media:/app/media - ./media:/app/media
- ./staticfiles:/app/staticfiles - ./staticfiles:/app/staticfiles
@@ -35,3 +44,4 @@ services:
volumes: volumes:
postgres_data: postgres_data:
media_data:
+9 -3
View File
@@ -6,8 +6,8 @@ until python -c "
import os, psycopg2, sys import os, psycopg2, sys
try: try:
psycopg2.connect( psycopg2.connect(
dbname=os.environ.get('POSTGRES_DB', 'tecvico'), dbname=os.environ.get('POSTGRES_DB', 'com2care'),
user=os.environ.get('POSTGRES_USER', 'tecvico_user'), user=os.environ.get('POSTGRES_USER', 'com2care_user'),
password=os.environ.get('POSTGRES_PASSWORD', ''), password=os.environ.get('POSTGRES_PASSWORD', ''),
host=os.environ.get('POSTGRES_HOST', 'db'), host=os.environ.get('POSTGRES_HOST', 'db'),
port=os.environ.get('POSTGRES_PORT', '5432'), port=os.environ.get('POSTGRES_PORT', '5432'),
@@ -28,7 +28,13 @@ python manage.py collectstatic --noinput
echo "Running database migrations..." echo "Running database migrations..."
python manage.py migrate --noinput python manage.py migrate --noinput
echo "Creating superuser if not exists..." echo "Normalizing the public brand..."
python manage.py normalize_brand
echo "Adding demonstration content when the database is empty..."
python manage.py seed_content --if-empty
echo "Ensuring the first admin account exists..."
python manage.py ensure_superuser python manage.py ensure_superuser
echo "Starting application..." echo "Starting application..."
+3
View File
@@ -4,6 +4,9 @@ import sys
def main(): def main():
if len(sys.argv) > 1 and sys.argv[1] == "test":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.test")
else:
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development")
try: try:
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
+535 -17
View File
@@ -45,6 +45,9 @@
--spacing-2xl: 7rem; --spacing-2xl: 7rem;
--navbar-height: 68px; --navbar-height: 68px;
--navbar-expand-duration: 0.85s;
--navbar-link-duration: 0.28s;
--navbar-expand-ease: cubic-bezier(0.4, 0, 0.1, .9);
--container-max: 1200px; --container-max: 1200px;
--container-padding: 1.5rem; --container-padding: 1.5rem;
@@ -378,7 +381,8 @@ h1, h2, h3, h4, h5, h6 {
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
text-decoration: none; text-decoration: none;
transition: var(--transition-fast); transition: color var(--navbar-link-duration) var(--navbar-expand-ease),
background var(--navbar-link-duration) var(--navbar-expand-ease);
white-space: nowrap; white-space: nowrap;
} }
@@ -389,7 +393,7 @@ h1, h2, h3, h4, h5, h6 {
} }
.nav-arrow { .nav-arrow {
transition: transform 0.4s ease; transition: transform var(--navbar-expand-duration) var(--navbar-expand-ease);
} }
.nav-item.has-megamenu:hover .nav-arrow, .nav-item.has-megamenu:hover .nav-arrow,
@@ -427,7 +431,9 @@ h1, h2, h3, h4, h5, h6 {
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transform: translateY(-10px); transform: translateY(-10px);
transition: opacity 0.6s ease, visibility 0.6s ease, transform 0.6s ease; transition: opacity var(--navbar-expand-duration) var(--navbar-expand-ease),
visibility var(--navbar-expand-duration) var(--navbar-expand-ease),
transform var(--navbar-expand-duration) var(--navbar-expand-ease);
pointer-events: none; pointer-events: none;
z-index: 999; z-index: 999;
} }
@@ -440,6 +446,10 @@ h1, h2, h3, h4, h5, h6 {
pointer-events: auto; pointer-events: auto;
} }
.megamenu.is-closing {
pointer-events: auto;
}
.megamenu-inner { .megamenu-inner {
max-width: var(--container-max); max-width: var(--container-max);
margin: 0 auto; margin: 0 auto;
@@ -522,7 +532,8 @@ h1, h2, h3, h4, h5, h6 {
.megamenu-sub-chevron { .megamenu-sub-chevron {
flex-shrink: 0; flex-shrink: 0;
color: var(--text-muted); color: var(--text-muted);
transition: transform 0.4s ease, color 0.4s ease; transition: transform var(--navbar-expand-duration) var(--navbar-expand-ease),
color var(--navbar-expand-duration) var(--navbar-expand-ease);
} }
.megamenu-product-item.has-subproducts:hover .megamenu-sub-chevron, .megamenu-product-item.has-subproducts:hover .megamenu-sub-chevron,
@@ -549,7 +560,9 @@ h1, h2, h3, h4, h5, h6 {
max-height: 0; max-height: 0;
overflow: hidden; overflow: hidden;
opacity: 0; opacity: 0;
transition: max-height 0.4s ease, opacity 0.35s ease, border-color 0.35s ease; transition: max-height var(--navbar-expand-duration) var(--navbar-expand-ease),
opacity var(--navbar-expand-duration) var(--navbar-expand-ease),
border-color var(--navbar-expand-duration) var(--navbar-expand-ease);
} }
.megamenu-product-item.has-subproducts:hover .megamenu-sublist, .megamenu-product-item.has-subproducts:hover .megamenu-sublist,
@@ -826,6 +839,12 @@ h1, h2, h3, h4, h5, h6 {
font-size: 2rem; font-size: 2rem;
margin-bottom: 1rem; margin-bottom: 1rem;
display: block; display: block;
line-height: 1;
}
.feature-icon--inline {
font-size: 1.25rem;
margin-bottom: 0.35rem;
} }
.feature-title { .feature-title {
@@ -840,6 +859,44 @@ h1, h2, h3, h4, h5, h6 {
line-height: 1.65; line-height: 1.65;
} }
a.section-item-link {
text-decoration: none;
color: inherit;
transition: var(--transition-fast);
}
a.section-item-link:hover {
border-color: rgba(79, 142, 247, 0.35);
}
a.supporter-card.section-item-link {
display: flex;
}
a.screenshot-item.section-item-link {
display: block;
}
a.faq-item--link.section-item-link {
display: block;
padding: 1.25rem 1.5rem;
}
a.faq-item--link .faq-question {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 1rem;
font-weight: 600;
margin: 0;
}
a.faq-item--link .faq-link-preview {
margin-top: 0.75rem;
font-size: 0.88rem;
color: var(--text-secondary);
}
/* ============================================================ /* ============================================================
Products Grid (Home) Products Grid (Home)
============================================================ */ ============================================================ */
@@ -1676,6 +1733,43 @@ a.citation-count-badge:hover {
padding: 1.75rem; padding: 1.75rem;
} }
.history-link-icon {
margin-right: 0.35rem;
}
.history-visual-image {
position: relative;
z-index: 2;
max-width: 100%;
max-height: 160px;
object-fit: contain;
border-radius: var(--radius-sm);
}
.about-hero-gallery {
margin-top: 2.5rem;
}
.about-section-screenshots {
margin-top: 1.5rem;
}
.about-hero-gallery:not(:has(.screenshot-item)) {
display: none;
}
.about-section-screenshots:not(:has(.screenshot-item)) {
display: none;
}
.standards-grid:not(:has(.standard-card)) {
display: none;
}
.history-visual:has(.history-visual-image) .history-year {
display: none;
}
.standard-badge { .standard-badge {
display: inline-block; display: inline-block;
font-size: 0.7rem; font-size: 0.7rem;
@@ -1901,12 +1995,48 @@ a.citation-count-badge:hover {
border: 1px solid rgba(34, 197, 94, 0.2); border: 1px solid rgba(34, 197, 94, 0.2);
} }
.release-channel-label--beta {
color: rgb(253, 186, 116);
background: rgba(249, 115, 22, 0.12);
border: 1px solid rgba(249, 115, 22, 0.22);
}
.release-channel-label--rc {
color: rgb(196, 181, 253);
background: rgba(139, 92, 246, 0.12);
border: 1px solid rgba(139, 92, 246, 0.22);
}
.release-channel-label--preview {
color: rgb(147, 197, 253);
background: rgba(59, 130, 246, 0.12);
border: 1px solid rgba(59, 130, 246, 0.22);
}
.release-channel-label--nightly {
color: rgb(252, 165, 165);
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.release-channel-label--current {
color: var(--accent-cyan);
background: rgba(34, 211, 238, 0.08);
border: 1px solid rgba(34, 211, 238, 0.2);
}
.release-channel-label--previous { .release-channel-label--previous {
color: var(--text-secondary); color: var(--text-secondary);
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
} }
.release-channel-label--custom {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--glass-border);
}
.download-card--previous { .download-card--previous {
opacity: 0.78; opacity: 0.78;
} }
@@ -2030,16 +2160,31 @@ a.citation-count-badge:hover {
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
} }
.sub-downloads-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
}
.sub-downloads-title { .sub-downloads-title {
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 1.25rem; margin-bottom: 0;
color: var(--text-primary); color: var(--text-primary);
} }
.sub-downloads-corner-link {
flex-shrink: 0;
font-size: 0.88rem;
white-space: nowrap;
}
.sub-downloads-channel { .sub-downloads-channel {
margin-bottom: 1.1rem; margin-bottom: 1.1rem;
} }
@@ -2146,9 +2291,22 @@ a.citation-count-badge:hover {
flex-shrink: 0; flex-shrink: 0;
} }
.pkg-stable-inline { .pkg-version-meta .release-channel-label {
font-size: 0.7rem; font-size: 0.7rem;
padding: 0.2rem 0.55rem; padding: 0.2rem 0.55rem;
margin-bottom: 0;
}
.versions-table-version {
display: inline-block;
margin-right: 0.45rem;
}
.versions-table th[scope="row"] .release-channel-label {
font-size: 0.68rem;
padding: 0.18rem 0.5rem;
margin-bottom: 0;
vertical-align: middle;
} }
.pkg-version-actions { .pkg-version-actions {
@@ -2241,10 +2399,29 @@ a.citation-count-badge:hover {
.versions-table-link { .versions-table-link {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
padding: 0.35rem;
border-radius: var(--radius-sm);
transition: var(--transition-smooth);
}
.versions-table-link:hover {
background: rgba(79, 142, 247, 0.1);
} }
.versions-table-icon { .versions-table-icon {
display: block; display: block;
width: 22px;
height: 22px;
object-fit: contain;
filter: brightness(0) invert(1);
opacity: 0.82;
transition: var(--transition-smooth);
}
.versions-table-link:hover .versions-table-icon {
opacity: 1;
filter: brightness(0) invert(1) drop-shadow(0 0 6px rgba(79, 142, 247, 0.55));
} }
.versions-table-text-link { .versions-table-text-link {
@@ -2436,6 +2613,38 @@ a.citation-count-badge:hover {
margin-left: 0.25rem; margin-left: 0.25rem;
} }
.form-hint {
font-size: 0.78rem;
color: var(--text-muted, var(--text-secondary));
margin-top: 0.35rem;
line-height: 1.4;
}
.form-file-list {
font-size: 0.82rem;
color: var(--text-secondary);
margin-top: 0.35rem;
min-height: 1.2em;
}
.form-group input[type="file"] {
padding: 0.55rem 0.75rem;
cursor: pointer;
}
.form-group input[type="file"]::file-selector-button {
margin-right: 0.75rem;
padding: 0.4rem 0.85rem;
border-radius: var(--radius-sm);
border: 1px solid var(--glass-border);
background: rgba(79, 142, 247, 0.12);
color: var(--text-primary);
font-family: inherit;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
}
.form-group input, .form-group input,
.form-group textarea, .form-group textarea,
.form-group select { .form-group select {
@@ -2466,7 +2675,8 @@ a.citation-count-badge:hover {
} }
.form-group.has-error input, .form-group.has-error input,
.form-group.has-error textarea { .form-group.has-error textarea,
.form-group.has-error input[type="file"] {
border-color: rgba(239, 68, 68, 0.5); border-color: rgba(239, 68, 68, 0.5);
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
} }
@@ -2853,7 +3063,8 @@ a.citation-count-badge:hover {
padding: 1rem var(--container-padding) 1.5rem; padding: 1rem var(--container-padding) 1.5rem;
max-height: 0; max-height: 0;
overflow: hidden; overflow: hidden;
transition: max-height 0.35s ease, padding 0.35s ease; transition: max-height var(--navbar-expand-duration) var(--navbar-expand-ease),
padding var(--navbar-expand-duration) var(--navbar-expand-ease);
} }
.navbar-menu.open { .navbar-menu.open {
@@ -2874,15 +3085,50 @@ a.citation-count-badge:hover {
font-size: 0.95rem; font-size: 0.95rem;
} }
.nav-item.has-megamenu:hover .nav-arrow {
transform: none;
}
.nav-item.has-megamenu.open .nav-arrow {
transform: rotate(180deg);
}
.megamenu { .megamenu {
position: static; position: static;
background: rgba(255, 255, 255, 0.03); max-height: 0;
border: 1px solid var(--glass-border); overflow: hidden;
margin: 0;
padding: 0;
border: none;
border-radius: var(--radius-md); border-radius: var(--radius-md);
transform: none; transform: none;
box-shadow: none; box-shadow: none;
backdrop-filter: none; backdrop-filter: none;
-webkit-backdrop-filter: none; -webkit-backdrop-filter: none;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.nav-item.has-megamenu:hover .megamenu {
opacity: 0;
visibility: hidden;
max-height: 0;
pointer-events: none;
transform: none;
}
.nav-item.has-megamenu.open .megamenu {
max-height: 2000px;
overflow: visible;
margin-top: 0.25rem;
padding: 0;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--glass-border);
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: none;
} }
.megamenu-inner { .megamenu-inner {
@@ -2904,14 +3150,28 @@ a.citation-count-badge:hover {
max-width: none; max-width: none;
} }
.megamenu-product-item.has-subproducts .megamenu-sublist { .megamenu-product-item.has-subproducts:hover .megamenu-sub-chevron,
max-height: none; .megamenu-product-item.has-subproducts:focus-within .megamenu-sub-chevron {
opacity: 1; transform: none;
border-top-color: var(--glass-border); color: var(--text-muted);
} }
.nav-item.has-megamenu:hover .megamenu { .megamenu-product-item.has-subproducts:hover .megamenu-sublist,
transform: none; .megamenu-product-item.has-subproducts:focus-within .megamenu-sublist {
max-height: 0;
opacity: 0;
border-top-color: transparent;
}
.megamenu-product-item.has-subproducts.open .megamenu-sub-chevron {
transform: rotate(180deg);
color: var(--accent-blue-light);
}
.megamenu-product-item.has-subproducts.open .megamenu-sublist {
max-height: 320px;
opacity: 1;
border-top-color: var(--glass-border);
} }
.sub-product-grid { .sub-product-grid {
@@ -3248,3 +3508,261 @@ a.citation-count-badge:hover {
.readmore-btn.is-open svg { .readmore-btn.is-open svg {
transform: rotate(180deg); transform: rotate(180deg);
} }
/* ============================================================
Video Blocks
============================================================ */
.video-block {
width: 100%;
max-width: var(--video-max-width, 100%);
margin-inline: auto;
}
.video-block--sm {
--video-max-width: 480px;
}
.video-block--md {
--video-max-width: 720px;
}
.video-block--lg {
--video-max-width: 960px;
}
.video-block--full {
--video-max-width: 100%;
}
.video-block-inner {
position: relative;
width: 100%;
aspect-ratio: var(--video-aspect-ratio, 16 / 9);
overflow: hidden;
border-radius: var(--radius-md);
background: rgba(8, 12, 24, 0.55);
}
.video-block--ratio-16-9 {
--video-aspect-ratio: 16 / 9;
}
.video-block--ratio-4-3 {
--video-aspect-ratio: 4 / 3;
}
.video-block--ratio-1-1 {
--video-aspect-ratio: 1 / 1;
}
.video-block-inner iframe,
.video-block-inner video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
object-fit: contain;
background: #000;
}
.video-section .section-desc {
margin-bottom: 1.5rem;
text-align: center;
max-width: 680px;
margin-left: auto;
margin-right: auto;
}
.product-videos-list {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
.product-video-header {
margin-bottom: 0.75rem;
}
.video-panel--styled {
position: relative;
overflow: hidden;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
padding: 1.75rem 1.5rem 1.5rem;
backdrop-filter: blur(12px);
}
.video-panel-ambient {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.video-panel-blob {
position: absolute;
border-radius: 50%;
filter: blur(52px);
}
.video-panel-blob--1 {
width: 240px;
height: 240px;
top: -90px;
right: -50px;
background: radial-gradient(circle, rgba(79, 142, 247, 0.42) 0%, transparent 72%);
opacity: 0.85;
}
.video-panel-blob--2 {
width: 200px;
height: 200px;
bottom: -70px;
left: -40px;
background: radial-gradient(circle, rgba(56, 189, 248, 0.28) 0%, transparent 72%);
opacity: 0.9;
}
.video-panel-blob--3 {
width: 140px;
height: 140px;
top: 45%;
left: 55%;
background: radial-gradient(circle, rgba(139, 92, 246, 0.18) 0%, transparent 70%);
opacity: 0.75;
}
.video-panel-inner {
position: relative;
z-index: 1;
}
.video-panel-header {
text-align: center;
max-width: 680px;
margin: 0 auto 0.35rem;
}
.video-panel-heading {
display: flex;
flex-direction: column;
align-items: center;
}
.video-panel-heading .section-badge {
margin-bottom: 0.65rem;
}
.video-panel-title {
font-size: 1.05rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: 0.55rem;
margin: 0;
color: var(--text-primary);
}
.video-panel-title svg {
flex-shrink: 0;
color: var(--accent-blue-light);
}
.video-panel-desc {
text-align: center;
max-width: 680px;
margin: 0 auto 1.25rem;
}
.video-panel--styled .video-block-inner {
border: 1px solid rgba(79, 142, 247, 0.22);
box-shadow:
0 16px 48px rgba(8, 12, 24, 0.38),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.product-videos-list .product-video-item .section-desc,
.product-videos-list .product-video-item .rich-content.section-desc {
text-align: center;
max-width: 680px;
margin-left: auto;
margin-right: auto;
}
.product-videos-list .video-panel--styled + .video-panel--styled,
.product-videos-list .product-video-item + .product-video-item {
margin-top: 0;
}
.product-videos-list .video-panel--styled,
.product-videos-list .product-video-item {
margin-top: 0;
}
.video-preview-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.video-preview-heading .section-badge { margin-bottom: 0.55rem; }
.video-preview-heading h2 { font-size: clamp(1.4rem, 3vw, 2rem); }
.video-preview-heading > span { color: var(--text-muted); font-size: 0.76rem; font-weight: 700; }
.video-preview-more { display: flex; justify-content: center; margin-top: 1.75rem; }
.video-library-hero .page-hero-content { max-width: none; }
.page-hero-content--row { display: flex; align-items: flex-end; justify-content: space-between; gap: 2rem; }
.video-library-section { background: var(--bg-secondary); }
.video-library-summary { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1.25rem; color: var(--text-secondary); font-size: 0.8rem; }
.video-library-summary strong { color: var(--text-primary); font-size: 1rem; }
.video-library-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.5rem; }
.video-library-card { min-width: 0; padding: 1.2rem; border: 1px solid var(--glass-border); border-radius: var(--radius-md); background: var(--glass-bg); box-shadow: var(--glass-shadow); }
.video-library-card .video-panel--styled { height: 100%; padding: 1.25rem; }
.video-library-card .video-panel-title,
.video-library-card .section-title { font-size: 1.05rem; }
.video-library-card .section-header { margin-bottom: 1rem; text-align: left; }
.video-library-card .section-desc { margin-bottom: 1rem; font-size: 0.78rem; }
.video-library-empty { grid-column: 1 / -1; }
.video-pagination { display: grid; grid-template-columns: minmax(110px, 1fr) auto minmax(110px, 1fr); align-items: center; gap: 1rem; margin-top: 2.25rem; padding-top: 1.5rem; border-top: 1px solid var(--glass-border); }
.video-pagination__pages { display: flex; align-items: center; gap: 0.35rem; }
.video-pagination__page,
.video-pagination__ellipsis { display: grid; place-items: center; min-width: 38px; height: 38px; padding: 0 0.4rem; border: 1px solid var(--glass-border); border-radius: var(--radius-sm); background: var(--glass-bg); color: var(--text-secondary); font-size: 0.76rem; font-weight: 700; }
.video-pagination__page:hover { border-color: var(--accent-blue); color: var(--accent-blue-light); }
.video-pagination__page.is-current { border-color: var(--accent-blue); background: var(--accent-blue); color: var(--text-primary); }
.video-pagination__ellipsis { border-color: transparent; background: transparent; }
.video-pagination__direction { color: var(--accent-blue-light); font-size: 0.78rem; font-weight: 700; }
.video-pagination__direction:last-child { justify-self: end; }
.video-pagination__direction.is-disabled { color: var(--text-muted); pointer-events: none; }
@media (max-width: 768px) {
.video-library-grid { grid-template-columns: 1fr; }
.video-pagination { grid-template-columns: 1fr 1fr; }
.video-pagination__pages { grid-column: 1 / -1; grid-row: 1; justify-content: center; flex-wrap: wrap; }
.video-pagination__direction { grid-row: 2; }
.video-preview-heading,
.page-hero-content--row { align-items: flex-start; flex-direction: column; }
}
@media (max-width: 640px) {
.video-panel--styled {
padding: 1.25rem 1rem 1rem;
}
.video-panel-blob--1 {
width: 180px;
height: 180px;
top: -70px;
right: -70px;
}
.video-panel-blob--2 {
width: 150px;
height: 150px;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 88" role="img" aria-labelledby="title desc">
<title id="title">com2care</title>
<desc id="desc">Communication to Care logo</desc>
<defs>
<linearGradient id="logoBubbleA" x1="8" y1="8" x2="48" y2="52" gradientUnits="userSpaceOnUse">
<stop stop-color="#9b7cf6"/>
<stop offset="1" stop-color="#6d5ee7"/>
</linearGradient>
<linearGradient id="logoBubbleB" x1="22" y1="18" x2="58" y2="57" gradientUnits="userSpaceOnUse">
<stop stop-color="#44d6cc"/>
<stop offset="1" stop-color="#239fbd"/>
</linearGradient>
<linearGradient id="wordmark" x1="88" y1="18" x2="388" y2="72" gradientUnits="userSpaceOnUse">
<stop stop-color="#f4f1ff"/>
<stop offset=".55" stop-color="#c9c2f7"/>
<stop offset="1" stop-color="#73e0da"/>
</linearGradient>
</defs>
<g transform="translate(4 12)">
<path fill="url(#logoBubbleA)" d="M8 10.5A8.5 8.5 0 0 1 16.5 2h25A8.5 8.5 0 0 1 50 10.5v18a8.5 8.5 0 0 1-8.5 8.5H28.2L16 47.2V37.1a8.5 8.5 0 0 1-8-8.5z"/>
<path fill="url(#logoBubbleB)" d="M24 24.5a8.5 8.5 0 0 1 8.5-8.5h15a8.5 8.5 0 0 1 8.5 8.5v17a8.5 8.5 0 0 1-8.5 8.5H45v10l-12-10h-.5a8.5 8.5 0 0 1-8.5-8.5z"/>
<path fill="#fff" d="M40 41.9c-1.2-1-7.1-5.6-7.1-10.1 0-2.8 2.1-4.8 4.8-4.8 1.5 0 2.7.7 3.5 1.8A4.3 4.3 0 0 1 44.7 27c2.7 0 4.8 2 4.8 4.8 0 4.5-5.9 9.1-7.1 10.1l-1.2 1z"/>
</g>
<text x="84" y="60" fill="url(#wordmark)" font-family="Inter, Arial, sans-serif" font-size="50" font-weight="700" letter-spacing="-2">com2care</text>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title desc">
<title id="title">com2care mark</title>
<desc id="desc">Two connected conversation shapes forming a care heart</desc>
<defs>
<linearGradient id="bubbleA" x1="8" y1="8" x2="48" y2="52" gradientUnits="userSpaceOnUse">
<stop stop-color="#9b7cf6"/>
<stop offset="1" stop-color="#6d5ee7"/>
</linearGradient>
<linearGradient id="bubbleB" x1="22" y1="18" x2="58" y2="57" gradientUnits="userSpaceOnUse">
<stop stop-color="#44d6cc"/>
<stop offset="1" stop-color="#239fbd"/>
</linearGradient>
</defs>
<path fill="url(#bubbleA)" d="M8 10.5A8.5 8.5 0 0 1 16.5 2h25A8.5 8.5 0 0 1 50 10.5v18a8.5 8.5 0 0 1-8.5 8.5H28.2L16 47.2V37.1a8.5 8.5 0 0 1-8-8.5z"/>
<path fill="url(#bubbleB)" d="M24 24.5a8.5 8.5 0 0 1 8.5-8.5h15a8.5 8.5 0 0 1 8.5 8.5v17a8.5 8.5 0 0 1-8.5 8.5H45v10l-12-10h-.5a8.5 8.5 0 0 1-8.5-8.5z"/>
<path fill="#fff" d="M40 41.9c-1.2-1-7.1-5.6-7.1-10.1 0-2.8 2.1-4.8 4.8-4.8 1.5 0 2.7.7 3.5 1.8A4.3 4.3 0 0 1 44.7 27c2.7 0 4.8 2 4.8 4.8 0 4.5-5.9 9.1-7.1 10.1l-1.2 1z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 454 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 834 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

+105 -12
View File
@@ -12,6 +12,12 @@ const SELECTORS = {
citationCopy: '.citation-copy', citationCopy: '.citation-copy',
}; };
const MOBILE_NAV_MQ = window.matchMedia('(max-width: 768px)');
function isMobileNav() {
return MOBILE_NAV_MQ.matches;
}
function initNavbarScroll() { function initNavbarScroll() {
const navbar = document.querySelector(SELECTORS.navbar); const navbar = document.querySelector(SELECTORS.navbar);
if (!navbar) return; if (!navbar) return;
@@ -28,6 +34,26 @@ function initNavbarScroll() {
onScroll(); onScroll();
} }
function closeMobileNavMenu() {
const toggle = document.querySelector(SELECTORS.navbarToggle);
const menu = document.querySelector(SELECTORS.navbarMenu);
const productsItem = document.querySelector(SELECTORS.productsNavItem);
if (!toggle || !menu) return;
menu.classList.remove('open');
toggle.classList.remove('open');
toggle.setAttribute('aria-expanded', 'false');
if (productsItem) {
productsItem.classList.remove('open');
const productsTrigger = productsItem.querySelector('.nav-link');
if (productsTrigger) productsTrigger.setAttribute('aria-expanded', 'false');
productsItem.querySelectorAll('.megamenu-product-item.has-subproducts.open').forEach((item) => {
item.classList.remove('open');
});
}
}
function initMobileMenu() { function initMobileMenu() {
const toggle = document.querySelector(SELECTORS.navbarToggle); const toggle = document.querySelector(SELECTORS.navbarToggle);
const menu = document.querySelector(SELECTORS.navbarMenu); const menu = document.querySelector(SELECTORS.navbarMenu);
@@ -37,22 +63,26 @@ function initMobileMenu() {
const isOpen = menu.classList.toggle('open'); const isOpen = menu.classList.toggle('open');
toggle.classList.toggle('open', isOpen); toggle.classList.toggle('open', isOpen);
toggle.setAttribute('aria-expanded', String(isOpen)); toggle.setAttribute('aria-expanded', String(isOpen));
if (!isOpen) {
const productsItem = document.querySelector(SELECTORS.productsNavItem);
if (productsItem) {
productsItem.classList.remove('open');
const productsTrigger = productsItem.querySelector('.nav-link');
if (productsTrigger) productsTrigger.setAttribute('aria-expanded', 'false');
}
}
}); });
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && menu.classList.contains('open')) { if (e.key === 'Escape' && menu.classList.contains('open')) {
menu.classList.remove('open'); closeMobileNavMenu();
toggle.classList.remove('open');
toggle.setAttribute('aria-expanded', 'false');
toggle.focus(); toggle.focus();
} }
}); });
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!menu.contains(e.target) && !toggle.contains(e.target)) { if (!menu.contains(e.target) && !toggle.contains(e.target)) {
menu.classList.remove('open'); closeMobileNavMenu();
toggle.classList.remove('open');
toggle.setAttribute('aria-expanded', 'false');
} }
}); });
} }
@@ -65,37 +95,100 @@ function initMegaMenu() {
const megamenu = productsItem.querySelector('.megamenu'); const megamenu = productsItem.querySelector('.megamenu');
if (!trigger || !megamenu) return; if (!trigger || !megamenu) return;
const CLOSE_DELAY_MS = 150;
let hideTimer = null; let hideTimer = null;
function openMenu() { function isPointerInMenu(target) {
if (!target || !(target instanceof Node)) return false;
return productsItem.contains(target) || megamenu.contains(target);
}
function cancelClose() {
clearTimeout(hideTimer); clearTimeout(hideTimer);
hideTimer = null;
megamenu.classList.remove('is-closing');
}
function openMenu() {
if (isMobileNav()) return;
cancelClose();
productsItem.classList.add('open'); productsItem.classList.add('open');
trigger.setAttribute('aria-expanded', 'true'); trigger.setAttribute('aria-expanded', 'true');
} }
function scheduleClose() { function beginClose() {
hideTimer = setTimeout(() => { if (isMobileNav()) return;
productsItem.classList.remove('open'); productsItem.classList.remove('open');
trigger.setAttribute('aria-expanded', 'false'); trigger.setAttribute('aria-expanded', 'false');
}, 420); megamenu.classList.add('is-closing');
} }
function scheduleClose(event) {
if (isMobileNav()) return;
if (event && isPointerInMenu(event.relatedTarget)) return;
clearTimeout(hideTimer);
hideTimer = setTimeout(beginClose, CLOSE_DELAY_MS);
}
megamenu.addEventListener('transitionend', (event) => {
if (event.target !== megamenu) return;
if (event.propertyName !== 'opacity' && event.propertyName !== 'visibility') return;
if (productsItem.classList.contains('open')) return;
megamenu.classList.remove('is-closing');
});
productsItem.addEventListener('mouseenter', openMenu); productsItem.addEventListener('mouseenter', openMenu);
productsItem.addEventListener('mouseleave', scheduleClose); productsItem.addEventListener('mouseleave', scheduleClose);
megamenu.addEventListener('mouseenter', openMenu); megamenu.addEventListener('mouseenter', openMenu);
megamenu.addEventListener('mouseleave', scheduleClose); megamenu.addEventListener('mouseleave', scheduleClose);
trigger.addEventListener('keydown', (e) => { trigger.addEventListener('click', (e) => {
if (e.key === 'Enter' || e.key === ' ') { if (!isMobileNav()) return;
e.preventDefault(); e.preventDefault();
const isOpen = productsItem.classList.toggle('open'); const isOpen = productsItem.classList.toggle('open');
trigger.setAttribute('aria-expanded', String(isOpen)); trigger.setAttribute('aria-expanded', String(isOpen));
});
trigger.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (isMobileNav()) {
const isOpen = productsItem.classList.toggle('open');
trigger.setAttribute('aria-expanded', String(isOpen));
return;
}
const isOpen = productsItem.classList.toggle('open');
if (isOpen) {
openMenu();
} else {
cancelClose();
beginClose();
}
trigger.setAttribute('aria-expanded', String(isOpen));
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (isMobileNav()) {
productsItem.classList.remove('open'); productsItem.classList.remove('open');
trigger.setAttribute('aria-expanded', 'false'); trigger.setAttribute('aria-expanded', 'false');
return;
} }
cancelClose();
beginClose();
}
});
productsItem.querySelectorAll('.megamenu-product-item.has-subproducts').forEach((item) => {
const link = item.querySelector('.megamenu-product-link');
if (!link) return;
link.addEventListener('click', (e) => {
if (!isMobileNav()) return;
if (item.classList.contains('open')) return;
e.preventDefault();
item.classList.add('open');
});
}); });
} }
+13 -5
View File
@@ -4,11 +4,19 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="{% block meta_description %}Radiuma — Advanced Medical Imaging & Radiomics Solutions{% endblock %}" /> <meta name="description" content="{% block meta_description %}Communication to Care — Advanced Medical Imaging & Radiomics Solutions{% endblock %}" />
<title>{% block title %}Radiuma{% endblock %} | Radiuma</title> <meta property="og:site_name" content="Communication to Care" />
<link rel="icon" href="{% static 'images/favicon-16.png' %}" type="image/png" sizes="16x16" /> <meta property="og:type" content="website" />
<link rel="icon" href="{% static 'images/favicon-32.png' %}" type="image/png" sizes="32x32" /> <meta property="og:image" content="https://com2care.com{% static 'images/com2care-care-team.jpg' %}" />
<link rel="apple-touch-icon" href="{% static 'images/favicon-180.png' %}" sizes="180x180" /> <link rel="canonical" href="https://com2care.com{{ request.path }}" />
<title>{% block title %}Communication to Care{% endblock %} | Communication to Care</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 }}" />
{% else %}
<link rel="icon" href="{% static 'images/com2care-mark.svg' %}" type="image/svg+xml" />
<link rel="apple-touch-icon" href="{% static 'images/com2care-mark.svg' %}" />
{% endif %}
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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 href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
+2 -134
View File
@@ -1,140 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %}
{% block title %}About{% endblock %} {% block title %}About{% endblock %}
{% block meta_description %}Learn about Radiuma — Visualized & Standardized Environment for Radiomics Analysis, developed at UBC and BC Cancer Research Institute.{% endblock %} {% block meta_description %}Learn how Communication to Care helps multidisciplinary teams build clearer, reproducible medical-imaging research workflows.{% endblock %}
{% block content %} {% block content %}
{% include "partials/_page_sections.html" with sections=about_sections %}
{% for section in about_sections %}
{% if section.section_type == "hero" %}
<section class="page-hero" aria-labelledby="page-hero-heading">
<div class="page-hero-blobs" aria-hidden="true">
<div class="blob blob--1"></div>
<div class="blob blob--2"></div>
<img src="{% static 'images/blob-blue-2.svg' %}" class="blob-img blob-img--page-hero" alt="" />
<img src="{% static 'images/abstract-shapes-2.svg' %}" class="blob-img blob-img--page-corner" alt="" />
</div>
<div class="container">
<div class="page-hero-content">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h1 class="page-hero-title" id="page-hero-heading">{{ section.title }}</h1>{% endif %}
{% if section.subtitle %}<p class="page-hero-subtitle">{{ section.subtitle }}</p>{% endif %}
{% if section.content %}<div class="rich-content page-hero-body" data-readmore="160">{{ section.rendered_content }}</div>{% endif %}
</div>
</div>
</section>
{% elif section.section_type == "intro" %}
<section class="section" aria-labelledby="about-intro-{{ section.pk }}-heading">
<div class="container">
<div class="about-intro glass-card fade-in">
<div class="about-intro-text">
{% if section.title %}<h2 id="about-intro-{{ section.pk }}-heading">{{ section.title }}</h2>{% endif %}
{% if section.content %}
<div class="rich-content" data-readmore="200">{{ section.rendered_content }}</div>
{% endif %}
</div>
</div>
</div>
</section>
{% elif section.section_type == "grid" %}
<section class="section" aria-labelledby="grid-{{ 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="grid-{{ section.pk }}-heading">{{ section.title }}</h2>{% endif %}
</div>
{% endif %}
{% if section.content %}<div class="rich-content" data-readmore="200">{{ section.rendered_content }}</div>{% endif %}
{% with items=section.items.all %}
{% if items %}
<div class="standards-grid">
{% for item in items %}
<div class="standard-card glass-card fade-in">
{% if item.badge %}<div class="standard-badge">{{ item.badge }}</div>{% endif %}
{% if item.title %}<h3 class="standard-title">{{ item.title }}</h3>{% endif %}
{% if item.content %}<p class="standard-desc" data-readmore="88">{{ item.content }}</p>{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
</div>
</section>
{% elif section.section_type == "history" %}
<section class="section" aria-labelledby="history-{{ section.pk }}-heading">
<div class="container">
<div class="history-block glass-card fade-in">
<div class="history-content">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 id="history-{{ section.pk }}-heading">{{ section.title }}</h2>{% endif %}
{% if section.content %}<div class="rich-content" data-readmore="200">{{ section.rendered_content }}</div>{% endif %}
{% with links=section.items.all %}
{% if links %}
<div class="history-links">
{% for link in links %}
{% if link.url %}
<a href="{{ link.url }}" class="btn-ghost" target="_blank" rel="noopener noreferrer">{{ link.title }}</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endwith %}
</div>
<div class="history-visual" aria-hidden="true">
<div class="history-blob history-blob--1"></div>
<div class="history-blob history-blob--2"></div>
{% if section.subtitle %}<div class="history-year">{{ section.subtitle }}</div>{% endif %}
</div>
</div>
</div>
</section>
{% elif section.section_type == "custom" %}
<section class="section" aria-labelledby="custom-{{ 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="custom-{{ section.pk }}-heading">{{ section.title }}</h2>{% endif %}
</div>
{% endif %}
{% if section.content %}
<div class="glass-card fade-in about-custom-card">
<div class="rich-content" data-readmore="200">{{ section.rendered_content }}</div>
</div>
{% endif %}
{% with items=section.items.all %}
{% if items %}
<div class="standards-grid" style="margin-top: 1.5rem;">
{% for item in items %}
<div class="standard-card glass-card fade-in">
{% if item.badge %}<div class="standard-badge">{{ item.badge }}</div>{% endif %}
{% if item.title %}<h3 class="standard-title">{{ item.title }}</h3>{% endif %}
{% if item.content %}<p class="standard-desc" data-readmore="88">{{ item.content }}</p>{% endif %}
{% if item.url %}<a href="{{ item.url }}" class="btn-ghost btn--sm" style="margin-top: 0.75rem;" target="_blank" rel="noopener noreferrer">{{ item.title }}</a>{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
</div>
</section>
{% endif %}
{% empty %}
<section class="section">
<div class="container">
<p style="color: var(--text-secondary); text-align: center; padding: 4rem 0;">
No content configured yet. Add sections in the admin panel.
</p>
</div>
</section>
{% endfor %}
{% endblock %} {% endblock %}
+24 -3
View File
@@ -2,7 +2,7 @@
{% load static %} {% load static %}
{% block title %}Contact{% endblock %} {% block title %}Contact{% endblock %}
{% block meta_description %}Contact Radiuma — support for Radiuma software and general inquiries.{% endblock %} {% block meta_description %}Contact Communication to Care for com2care product support, research evaluation, and general inquiries.{% endblock %}
{% block content %} {% block content %}
@@ -15,18 +15,20 @@
<div class="page-hero-content"> <div class="page-hero-content">
<div class="section-badge">Get in Touch</div> <div class="section-badge">Get in Touch</div>
<h1 class="page-hero-title" id="contact-heading">Contact Us</h1> <h1 class="page-hero-title" id="contact-heading">Contact Us</h1>
<p class="page-hero-subtitle">Have a question or want to reach the Radiuma team? Fill out the form or contact us directly below.</p> <p class="page-hero-subtitle">Have a product, evaluation, or research question? Send the com2care team a message below.</p>
</div> </div>
</div> </div>
</section> </section>
{% include "partials/_page_videos.html" with videos=contact_videos %}
<section class="section" aria-labelledby="contact-form-heading"> <section class="section" aria-labelledby="contact-form-heading">
<div class="container"> <div class="container">
<div class="contact-layout{% if not site_contact.has_contact_sidebar %} contact-layout--full{% endif %}"> <div class="contact-layout{% if not site_contact.has_contact_sidebar %} contact-layout--full{% endif %}">
<div class="contact-form-wrap glass-card fade-in"> <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">Send a Message</h2>
<form id="contactForm" novalidate action="{% url 'pages:contact' %}" method="post"> <form id="contactForm" novalidate action="{% url 'pages:contact' %}" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<div class="form-group"> <div class="form-group">
@@ -53,6 +55,16 @@
<span class="form-field-error" data-field="email"></span> <span class="form-field-error" data-field="email"></span>
</div> </div>
<div class="form-group">
<label for="id_attachments">Attachments <span class="optional-label">(optional)</span></label>
<input type="file" name="attachments" id="id_attachments" multiple
accept=".png,.jpg,.jpeg,.gif,.webp,.bmp,.zip,.log,.txt"
class="file-input" />
<p class="form-hint">Images, ZIP, or log/text files — up to 3 files, 10 MB each.</p>
<p class="form-file-list" id="attachmentFileList" aria-live="polite"></p>
<span class="form-field-error" data-field="attachments"></span>
</div>
<div class="form-group form-group--captcha"> <div class="form-group form-group--captcha">
<label for="id_captcha"> <label for="id_captcha">
Verification: what is <strong id="captchaQuestion">{{ captcha_question }}</strong>? Verification: what is <strong id="captchaQuestion">{{ captcha_question }}</strong>?
@@ -178,6 +190,8 @@
if (!form) return; if (!form) return;
const emailField = form.querySelector('#id_email'); const emailField = form.querySelector('#id_email');
const fileInput = form.querySelector('#id_attachments');
const fileList = form.querySelector('#attachmentFileList');
const submitBtn = form.querySelector('#submitBtn'); const submitBtn = form.querySelector('#submitBtn');
const btnLabel = submitBtn.querySelector('.btn-label'); const btnLabel = submitBtn.querySelector('.btn-label');
const btnSpinner = submitBtn.querySelector('.btn-spinner'); const btnSpinner = submitBtn.querySelector('.btn-spinner');
@@ -226,6 +240,7 @@
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
form.reset(); form.reset();
if (fileList) fileList.textContent = '';
showModal('successModal'); showModal('successModal');
} else { } else {
if (data.errors) showErrors(data.errors); if (data.errors) showErrors(data.errors);
@@ -248,6 +263,12 @@
} }
} }
fileInput?.addEventListener('change', () => {
if (!fileList) return;
const names = [...fileInput.files].map(file => file.name);
fileList.textContent = names.length ? names.join(', ') : '';
});
form.addEventListener('submit', e => { form.addEventListener('submit', e => {
e.preventDefault(); e.preventDefault();
if (!emailField.value.trim()) { if (!emailField.value.trim()) {
+8
View File
@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block title %}{{ custom_page.title }}{% endblock %}
{% block meta_description %}{{ custom_page.meta_description|default:custom_page.title }}{% endblock %}
{% block content %}
{% include "partials/_page_sections.html" with sections=page_sections %}
{% endblock %}
+3 -1
View File
@@ -2,7 +2,7 @@
{% load static %} {% load static %}
{% block title %}FAQ{% endblock %} {% block title %}FAQ{% endblock %}
{% block meta_description %}Frequently asked questions about Radiuma by Radiuma — licensing, citation, system requirements, and more.{% endblock %} {% block meta_description %}Frequently asked questions about Communication to Care licensing, research use, releases, support, and responsible deployment.{% endblock %}
{% block content %} {% block content %}
@@ -20,6 +20,8 @@
</div> </div>
</section> </section>
{% include "partials/_page_videos.html" with videos=faq_videos %}
<section class="section" aria-labelledby="faq-list-heading"> <section class="section" aria-labelledby="faq-list-heading">
<div class="container"> <div class="container">
<h2 class="sr-only" id="faq-list-heading">FAQ List</h2> <h2 class="sr-only" id="faq-list-heading">FAQ List</h2>
+7 -158
View File
@@ -2,7 +2,7 @@
{% load static %} {% load static %}
{% block title %}Home{% endblock %} {% block title %}Home{% endblock %}
{% block meta_description %}Radiuma — A Powerful Workflow Generator for Standardized Radiomics Analysis and Medical Image Visualization.{% endblock %} {% block meta_description %}Communication to Care — A Powerful Workflow Generator for Standardized Radiomics Analysis and Medical Image Visualization.{% endblock %}
{% block content %} {% block content %}
@@ -35,170 +35,19 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </div>
{% if hero and hero.image or not hero %}
<div class="hero-app-preview fade-in"> <div class="hero-app-preview fade-in">
<div class="hero-app-preview-glow" aria-hidden="true"></div> <div class="hero-app-preview-glow" aria-hidden="true"></div>
{% if hero and hero.image %} {% if site_branding.hero_logo %}
<img src="{{ site_branding.hero_logo.url }}" alt="{{ site_branding.hero_logo_alt|default:'Communication to Care' }}" loading="eager" />
{% elif hero and hero.image %}
<img src="{{ hero.image.url }}" alt="{{ hero.image_alt }}" loading="eager" /> <img src="{{ hero.image.url }}" alt="{{ hero.image_alt }}" loading="eager" />
{% elif not hero %} {% else %}
<img src="{% static 'images/screenshot-1.jpg' %}" alt="Radiuma application — main workflow view" loading="eager" /> <img src="{% static 'images/com2care-care-team.jpg' %}" alt="A multidisciplinary care team collaborating around medical imaging" loading="eager" />
{% endif %} {% endif %}
</div> </div>
{% endif %}
</div> </div>
</section> </section>
{% for section in homepage_sections %} {% include "partials/_page_sections.html" with sections=homepage_sections %}
{% if section.section_type == "features" %}
<section class="section features-section" aria-labelledby="features-heading-{{ section.pk }}">
<div class="container">
<div class="section-header">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
<h2 class="section-title" id="features-heading-{{ section.pk }}">{{ section.title }}</h2>
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% endif %}
</div>
<div class="features-grid">
{% for item in section.items.all %}
<div class="feature-card glass-card fade-in">
{% if item.icon %}<div class="feature-icon" aria-hidden="true">{{ item.icon }}</div>{% endif %}
<h3 class="feature-title">{{ item.title }}</h3>
<p class="feature-desc" data-readmore="88">{{ item.content }}</p>
</div>
{% endfor %}
</div>
</div>
</section>
{% elif section.section_type == "screenshots" %}
<section class="section screenshots-section" aria-labelledby="screenshots-heading-{{ section.pk }}">
<div class="container">
<div class="section-header">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
<h2 class="section-title" id="screenshots-heading-{{ section.pk }}">{{ section.title }}</h2>
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% endif %}
</div>
<div class="screenshots-grid">
<div class="screenshot-item fade-in">
<img src="{% static 'images/screenshot-2.jpg' %}" alt="Radiuma full workflow interface" loading="lazy" />
</div>
<div class="screenshot-item fade-in">
<img src="{% static 'images/screenshot-3.png' %}" alt="Radiuma panel view" loading="lazy" />
</div>
<div class="screenshot-item fade-in">
<img src="{% static 'images/screenshot-5.png' %}" alt="Radiuma segmentation view" loading="lazy" />
</div>
<div class="screenshot-item fade-in">
<img src="{% static 'images/screenshot-8.jpg' %}" alt="Radiuma radiomics results" loading="lazy" />
</div>
</div>
</div>
</section>
{% elif section.section_type == "products" %}
{% if sub_products %}
<section class="section products-section" aria-labelledby="products-heading-{{ section.pk }}">
<div class="container">
<div class="section-header">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
<h2 class="section-title" id="products-heading-{{ section.pk }}">{{ section.title }}</h2>
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% endif %}
</div>
<div class="products-grid">
{% for product in sub_products %}
<a href="{{ product.get_absolute_url }}" class="product-card glass-card fade-in">
{% if product.image %}
<div class="product-card-image">
<img src="{{ product.image.url }}" alt="{{ product.name }}" loading="lazy" />
</div>
{% endif %}
<div class="product-card-body">
{% if product.logo %}
<img src="{{ product.logo.url }}" alt="" class="product-card-logo" aria-hidden="true" loading="lazy" />
{% endif %}
<h3 class="product-card-title">{{ product.name }}</h3>
<p class="product-card-desc" data-readmore="88">{{ product.short_description }}</p>
<span class="product-card-link">
Explore
<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>
</span>
</div>
</a>
{% endfor %}
</div>
</div>
</section>
{% endif %}
{% elif section.section_type == "problems" %}
<section class="section problems-section" aria-labelledby="problems-heading-{{ section.pk }}">
<div class="container">
<div class="section-header">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
<h2 class="section-title" id="problems-heading-{{ section.pk }}">{{ section.title }}</h2>
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% endif %}
</div>
<div class="problems-grid">
{% for item in section.items.all %}
<div class="problem-item glass-card fade-in">
{% if item.icon %}<div class="problem-number" aria-hidden="true">{{ item.icon }}</div>{% endif %}
<h3 class="problem-title">{{ item.title }}</h3>
<p class="problem-desc" data-readmore="88">{{ item.content }}</p>
</div>
{% endfor %}
</div>
</div>
</section>
{% elif section.section_type == "supporters" %}
<section class="section supporters-section" aria-labelledby="supporters-heading-{{ section.pk }}">
<div class="container">
<div class="section-header">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
<h2 class="section-title" id="supporters-heading-{{ section.pk }}">{{ section.title }}</h2>
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% endif %}
</div>
<div class="supporters-grid">
{% for item in section.items.all %}
<div class="supporter-card glass-card fade-in">
{% if item.image %}
<div class="supporter-logo">
<img src="{{ item.image.url }}" alt="{{ item.title }} logo" loading="lazy" />
</div>
{% endif %}
<div class="supporter-body">
<h3 class="supporter-name">{{ item.title }}</h3>
{% if item.content %}<p class="supporter-desc">{{ item.content }}</p>{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</section>
{% elif section.section_type == "about_strip" %}
<section class="section about-strip" aria-labelledby="about-strip-heading-{{ section.pk }}">
<div class="container">
<div class="about-strip-inner glass-card">
<div class="about-strip-content">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
<h2 class="section-title" id="about-strip-heading-{{ section.pk }}">{{ section.title }}</h2>
{% if section.description %}<p class="about-strip-text">{{ section.description }}</p>{% endif %}
{% if section.link_text and section.link_url %}
<a href="{{ section.link_url }}" class="btn-primary">{{ section.link_text }}</a>
{% endif %}
</div>
<div class="about-strip-blobs" aria-hidden="true">
<div class="strip-blob strip-blob--1"></div>
<div class="strip-blob strip-blob--2"></div>
</div>
</div>
</div>
</section>
{% endif %}
{% endfor %}
{% endblock %} {% endblock %}
+2 -6
View File
@@ -1,20 +1,16 @@
{% load static %} {% load static %}
{% if placement == "footer" %} {% if placement == "footer" %}
<img <img
{% if site_branding.icon %}src="{{ site_branding.icon.url }}"{% else %}src="{% static 'images/favicon-180.png' %}"{% endif %} {% if site_branding.icon %}src="{{ site_branding.icon.url }}"{% else %}src="{% static 'images/com2care-mark.svg' %}"{% endif %}
{% if site_branding.icon_alt %}alt="{{ site_branding.icon_alt }}"{% else %}alt="" aria-hidden="true"{% endif %} {% if site_branding.icon_alt %}alt="{{ site_branding.icon_alt }}"{% else %}alt="" aria-hidden="true"{% endif %}
class="brand-icon" class="brand-icon"
width="{{ site_branding.footer_icon_size }}"
height="{{ site_branding.footer_icon_size }}"
style="{{ site_branding.footer_icon_style }}" style="{{ site_branding.footer_icon_style }}"
/> />
{% else %} {% else %}
<img <img
{% if site_branding.icon %}src="{{ site_branding.icon.url }}"{% else %}src="{% static 'images/favicon-180.png' %}"{% endif %} {% if site_branding.icon %}src="{{ site_branding.icon.url }}"{% else %}src="{% static 'images/com2care-mark.svg' %}"{% endif %}
{% if site_branding.icon_alt %}alt="{{ site_branding.icon_alt }}"{% else %}alt="" aria-hidden="true"{% endif %} {% if site_branding.icon_alt %}alt="{{ site_branding.icon_alt }}"{% else %}alt="" aria-hidden="true"{% endif %}
class="brand-icon" class="brand-icon"
width="{{ site_branding.navbar_icon_size }}"
height="{{ site_branding.navbar_icon_size }}"
style="{{ site_branding.navbar_icon_style }}" style="{{ site_branding.navbar_icon_style }}"
/> />
{% endif %} {% endif %}
+7 -9
View File
@@ -8,12 +8,12 @@
<div class="footer-grid"> <div class="footer-grid">
<div class="footer-brand-col"> <div class="footer-brand-col">
<a href="{% url 'pages:home' %}" class="footer-logo" aria-label="Radiuma home"> <a href="{% url 'pages:home' %}" class="footer-logo" aria-label="Communication to Care home">
{% include "partials/_brand_icon.html" with placement="footer" %} {% include "partials/_brand_icon.html" with placement="footer" %}
<span class="brand-name">Radiuma</span> <span class="brand-name">Communication to Care</span>
</a> </a>
<p class="footer-tagline"> <p class="footer-tagline">
Advancing medical imaging and radiomics research through innovative, standardized software solutions. Turning complex medical-imaging evidence into clearer, reproducible research conversations.
</p> </p>
{% include "partials/_contact_discord.html" with button_class="btn-ghost btn-sm" %} {% include "partials/_contact_discord.html" with button_class="btn-ghost btn-sm" %}
</div> </div>
@@ -22,7 +22,7 @@
<h3 class="footer-heading">Navigation</h3> <h3 class="footer-heading">Navigation</h3>
<ul class="footer-links" role="list"> <ul class="footer-links" role="list">
<li><a href="{% url 'pages:home' %}">Home</a></li> <li><a href="{% url 'pages:home' %}">Home</a></li>
<li><a href="{% url 'pages:about' %}">What is Radiuma</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 'products:overview' %}">Products</a></li>
<li><a href="{% url 'pages:faq' %}">FAQ</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:contact' %}">Contact</a></li>
@@ -37,7 +37,7 @@
<li><a href="{{ product.get_absolute_url }}">{{ product.name }}</a></li> <li><a href="{{ product.get_absolute_url }}">{{ product.name }}</a></li>
{% endfor %} {% endfor %}
{% if footer_main_products_has_more %} {% if footer_main_products_has_more %}
<li><a href="{% url 'products:main_product_detail' main_slug='radiuma' %}">more</a></li> <li><a href="{% url 'products:overview' %}">More products</a></li>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
@@ -55,12 +55,10 @@
<div class="footer-bottom"> <div class="footer-bottom">
<p class="footer-copy"> <p class="footer-copy">
&copy; {% now "Y" %} Radiuma. All rights reserved. &copy; {% now "Y" %} Communication to Care. All rights reserved.
</p> </p>
<p class="footer-credit"> <p class="footer-credit">
Developed at <a href="https://www.qurit.ca" target="_blank" rel="noopener noreferrer">Qurit Lab</a>, Research collaboration at <a href="https://com2care.com">com2care.com</a>
<a href="https://www.ubc.ca" target="_blank" rel="noopener noreferrer">University of British Columbia</a>
&amp; <a href="https://www.bccrc.ca" target="_blank" rel="noopener noreferrer">BC Cancer Research Institute</a>
</p> </p>
</div> </div>
@@ -0,0 +1,5 @@
{% if url %}
</a>
{% else %}
</div>
{% endif %}
@@ -0,0 +1,5 @@
{% if url %}
<a href="{{ url }}" class="{{ card_class }} glass-card fade-in section-item-link">
{% else %}
<div class="{{ card_class }} glass-card fade-in">
{% endif %}
+10 -2
View File
@@ -2,9 +2,9 @@
<nav class="navbar" id="navbar" role="navigation" aria-label="Main navigation"> <nav class="navbar" id="navbar" role="navigation" aria-label="Main navigation">
<div class="navbar-container"> <div class="navbar-container">
<a href="{% url 'pages:home' %}" class="navbar-brand" aria-label="Radiuma home"> <a href="{% url 'pages:home' %}" class="navbar-brand" aria-label="Communication to Care home">
{% include "partials/_brand_icon.html" with placement="navbar" %} {% include "partials/_brand_icon.html" with placement="navbar" %}
<span class="brand-name">Radiuma</span> <span class="brand-name">com2care</span>
</a> </a>
<button class="navbar-toggle" id="navbarToggle" aria-expanded="false" aria-controls="navbarMenu" aria-label="Toggle navigation"> <button class="navbar-toggle" id="navbarToggle" aria-expanded="false" aria-controls="navbarMenu" aria-label="Toggle navigation">
@@ -71,6 +71,14 @@
</a> </a>
</li> </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 %}">
{{ page.nav_label }}
</a>
</li>
{% endfor %}
<li class="nav-item"> <li class="nav-item">
<a href="{% url 'pages:faq' %}" class="nav-link {% if request.resolver_match.url_name == 'faq' %}active{% endif %}"> <a href="{% url 'pages:faq' %}" class="nav-link {% if request.resolver_match.url_name == 'faq' %}active{% endif %}">
FAQ FAQ
+426
View File
@@ -0,0 +1,426 @@
{% load static %}
{% for section in sections %}
{% if section.section_type == "hero" %}
<section class="page-hero" aria-labelledby="page-hero-heading-{{ section.pk }}">
<div class="page-hero-blobs" aria-hidden="true">
<div class="blob blob--1"></div>
<div class="blob blob--2"></div>
<img src="{% static 'images/blob-blue-2.svg' %}" class="blob-img blob-img--page-hero" alt="" />
<img src="{% static 'images/abstract-shapes-2.svg' %}" class="blob-img blob-img--page-corner" alt="" />
</div>
<div class="container">
<div class="page-hero-content">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h1 class="page-hero-title" id="page-hero-heading-{{ section.pk }}">{{ section.title }}</h1>{% endif %}
{% if section.subtitle %}<p class="page-hero-subtitle">{{ section.subtitle }}</p>{% endif %}
{% if section.content %}<div class="rich-content page-hero-body" data-readmore="160">{{ section.rendered_content }}</div>{% endif %}
</div>
{% with items=section.items.all %}
{% if items %}
<div class="about-screenshots-grid about-hero-gallery">
{% for item in items %}
{% if item.image %}
{% if item.is_featured %}
{% include "partials/_linked_card_open.html" with url=item.url card_class="screenshot-item fade-in screenshot-item--featured" %}
{% else %}
{% include "partials/_linked_card_open.html" with url=item.url card_class="screenshot-item fade-in" %}
{% endif %}
<img src="{{ item.image.url }}" alt="{{ item.image_alt|default:item.title }}" loading="lazy" />
{% include "partials/_linked_card_close.html" with url=item.url %}
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endwith %}
</div>
</section>
{% elif section.section_type == "intro" %}
<section class="section" aria-labelledby="intro-{{ section.pk }}-heading">
<div class="container">
<div class="about-intro glass-card fade-in">
<div class="about-intro-text">
{% if section.title %}<h2 id="intro-{{ section.pk }}-heading">{{ section.title }}</h2>{% endif %}
{% if section.content %}
<div class="rich-content" data-readmore="200">{{ section.rendered_content }}</div>
{% endif %}
</div>
</div>
{% with items=section.items.all %}
{% if items %}
<div class="standards-grid" style="margin-top: 1.5rem;">
{% for item in items %}
{% if item.is_featured and item.image %}
{% else %}
{% include "partials/_standard_item_card.html" with item=item %}
{% endif %}
{% endfor %}
</div>
{% include "partials/_section_featured_screenshots.html" with items=items %}
{% endif %}
{% endwith %}
</div>
</section>
{% elif section.section_type == "grid" %}
<section class="section" aria-labelledby="grid-{{ 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="grid-{{ section.pk }}-heading">{{ section.title }}</h2>{% endif %}
</div>
{% endif %}
{% if section.content %}<div class="rich-content" data-readmore="200">{{ section.rendered_content }}</div>{% endif %}
{% with items=section.items.all %}
{% if items %}
<div class="standards-grid">
{% for item in items %}
{% if item.is_featured and item.image %}
{% else %}
{% include "partials/_standard_item_card.html" with item=item %}
{% endif %}
{% endfor %}
</div>
{% include "partials/_section_featured_screenshots.html" with items=items %}
{% endif %}
{% endwith %}
</div>
</section>
{% elif section.section_type == "history" %}
<section class="section" aria-labelledby="history-{{ section.pk }}-heading">
<div class="container">
<div class="history-block glass-card fade-in">
{% with links=section.items.all %}
<div class="history-content">
{% if section.badge %}<div class="section-badge">{{ section.badge }}</div>{% endif %}
{% if section.title %}<h2 id="history-{{ section.pk }}-heading">{{ section.title }}</h2>{% endif %}
{% if section.content %}<div class="rich-content" data-readmore="200">{{ section.rendered_content }}</div>{% endif %}
{% if links %}
<div class="history-links">
{% for link in links %}
{% if link.url %}
<a href="{{ link.url }}" class="btn-ghost" target="_blank" rel="noopener noreferrer">
{% if link.icon %}<span class="history-link-icon" aria-hidden="true">{{ link.icon }}</span>{% endif %}
{{ link.title }}
</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
<div class="history-visual" aria-hidden="true">
<div class="history-blob history-blob--1"></div>
<div class="history-blob history-blob--2"></div>
{% for link in links %}
{% if link.image %}
<img src="{{ link.image.url }}" alt="{{ link.image_alt|default:link.title }}" class="history-visual-image" loading="lazy" />
{% endif %}
{% endfor %}
{% if section.subtitle %}<div class="history-year">{{ section.subtitle }}</div>{% endif %}
</div>
{% endwith %}
</div>
</div>
</section>
{% elif section.section_type == "custom" %}
<section class="section" aria-labelledby="custom-{{ 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="custom-{{ section.pk }}-heading">{{ section.title }}</h2>{% endif %}
</div>
{% endif %}
{% if section.content %}
<div class="glass-card fade-in about-custom-card">
<div class="rich-content" data-readmore="200">{{ section.rendered_content }}</div>
</div>
{% endif %}
{% with items=section.items.all %}
{% if items %}
<div class="standards-grid" style="margin-top: 1.5rem;">
{% for item in items %}
{% if item.is_featured and item.image %}
{% else %}
{% include "partials/_standard_item_card.html" with item=item %}
{% endif %}
{% endfor %}
</div>
{% include "partials/_section_featured_screenshots.html" with items=items %}
{% endif %}
{% endwith %}
</div>
</section>
{% elif section.section_type == "features" %}
<section class="section features-section" aria-labelledby="features-heading-{{ section.pk }}">
<div class="container">
<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 %}
</div>
<div class="features-grid">
{% for item in section.items.all %}
{% include "partials/_linked_card_open.html" with url=item.url card_class="feature-card" %}
{% if item.icon %}<div class="feature-icon" aria-hidden="true">{{ item.icon }}</div>{% endif %}
<h3 class="feature-title">{{ item.title }}</h3>
<p class="feature-desc" data-readmore="88">{{ item.content }}</p>
{% include "partials/_linked_card_close.html" with url=item.url %}
{% endfor %}
</div>
</div>
</section>
{% elif section.section_type == "screenshots" %}
<section class="section screenshots-section" aria-labelledby="screenshots-heading-{{ section.pk }}">
<div class="container">
<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 %}
</div>
<div class="screenshots-grid">
{% for item in section.items.all %}
{% if item.image %}
{% include "partials/_linked_card_open.html" with url=item.url card_class="screenshot-item" %}
<img src="{{ item.image.url }}" alt="{{ item.image_alt|default:item.title }}" loading="lazy" />
{% include "partials/_linked_card_close.html" with url=item.url %}
{% endif %}
{% empty %}
<div class="screenshot-item fade-in">
<img src="{% static 'images/screenshot-2.jpg' %}" alt="Application screenshot" loading="lazy" />
</div>
<div class="screenshot-item fade-in">
<img src="{% static 'images/screenshot-3.png' %}" alt="Application screenshot" loading="lazy" />
</div>
<div class="screenshot-item fade-in">
<img src="{% static 'images/screenshot-5.png' %}" alt="Application screenshot" loading="lazy" />
</div>
<div class="screenshot-item fade-in">
<img src="{% static 'images/screenshot-8.jpg' %}" alt="Application screenshot" loading="lazy" />
</div>
{% endfor %}
</div>
</div>
</section>
{% elif section.section_type == "products" %}
{% if homepage_products %}
<section class="section products-section" aria-labelledby="products-heading-{{ section.pk }}">
<div class="container">
<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 %}
</div>
<div class="products-grid">
{% for product in homepage_products %}
<a href="{{ product.get_absolute_url }}" class="product-card glass-card fade-in">
{% if product.image %}
<div class="product-card-image">
<img src="{{ product.image.url }}" alt="{{ product.name }}" loading="lazy" />
</div>
{% endif %}
<div class="product-card-body">
<h3 class="product-card-title">{{ product.name }}</h3>
<p class="product-card-desc" data-readmore="88">{{ product.short_description }}</p>
<span class="product-card-link">
Explore
<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>
</span>
</div>
</a>
{% endfor %}
</div>
</div>
</section>
{% endif %}
{% elif section.section_type == "products_catalog" %}
{% if homepage_products_catalog %}
<section class="section products-catalog-section" aria-labelledby="products-catalog-heading-{{ section.pk }}">
<div class="container">
<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 %}
</div>
<div class="products-overview-grid">
{% for product in homepage_products_catalog %}
<div class="product-overview-card glass-card fade-in">
{% if product.image %}
<div class="product-overview-image">
<img src="{{ product.image.url }}" alt="{{ product.name }}" loading="lazy" />
</div>
{% endif %}
<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>
<a href="{{ product.get_absolute_url }}" class="btn-primary">Explore {{ product.name }}</a>
</div>
{% if product.sub_products.all %}
<div class="product-overview-subs">
<h4 class="product-overview-subs-title">Modules</h4>
<ul class="product-overview-subs-list" role="list">
{% for sub in product.sub_products.all %}
<li>
<a href="{{ sub.get_absolute_url }}" class="sub-link-chip">
{{ sub.name }}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M2.5 6H9.5M9.5 6L7 3.5M9.5 6L7 8.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</section>
{% endif %}
{% elif section.section_type == "problems" %}
<section class="section problems-section" aria-labelledby="problems-heading-{{ section.pk }}">
<div class="container">
<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 %}
</div>
<div class="problems-grid">
{% for item in section.items.all %}
{% include "partials/_linked_card_open.html" with url=item.url card_class="problem-item" %}
{% if item.icon %}<div class="problem-number" aria-hidden="true">{{ item.icon }}</div>{% endif %}
<h3 class="problem-title">{{ item.title }}</h3>
<p class="problem-desc" data-readmore="88">{{ item.content }}</p>
{% include "partials/_linked_card_close.html" with url=item.url %}
{% endfor %}
</div>
</div>
</section>
{% elif section.section_type == "supporters" %}
<section class="section supporters-section" aria-labelledby="supporters-heading-{{ section.pk }}">
<div class="container">
<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 %}
</div>
<div class="supporters-grid">
{% for item in section.items.all %}
{% include "partials/_linked_card_open.html" with url=item.url card_class="supporter-card" %}
{% if item.image %}
<div class="supporter-logo">
<img src="{{ item.image.url }}" alt="{{ item.image_alt|default:item.title }} logo" loading="lazy" />
</div>
{% endif %}
<div class="supporter-body">
<h3 class="supporter-name">{{ item.title }}</h3>
{% if item.content %}<p class="supporter-desc">{{ item.content }}</p>{% endif %}
</div>
{% include "partials/_linked_card_close.html" with url=item.url %}
{% endfor %}
</div>
</div>
</section>
{% elif section.section_type == "about_strip" %}
<section class="section about-strip" aria-labelledby="about-strip-heading-{{ section.pk }}">
<div class="container">
<div class="about-strip-inner glass-card">
<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.link_text and section.link_url %}
<a href="{{ section.link_url }}" class="btn-primary">{{ section.link_text }}</a>
{% endif %}
</div>
<div class="about-strip-blobs" aria-hidden="true">
<div class="strip-blob strip-blob--1"></div>
<div class="strip-blob strip-blob--2"></div>
</div>
</div>
</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 %}
{% endif %}
{% empty %}
<section class="section">
<div class="container">
<p style="color: var(--text-secondary); text-align: center; padding: 4rem 0;">
No content configured yet. Add sections in the admin panel.
</p>
</div>
</section>
{% endfor %}
+26
View File
@@ -0,0 +1,26 @@
{% if videos %}
<section class="section product-videos-section" aria-label="Featured videos">
<div class="container">
{% if videos_archive_url %}
<div class="video-preview-heading">
<div><span class="section-badge">Video library</span><h2>Featured videos</h2></div>
<span>{{ videos_total_count }} videos</span>
</div>
{% endif %}
<div class="product-videos-list">
{% for video in videos %}
{% if video.has_video %}
<div class="product-video-item">
{% include "partials/_video_block.html" with video=video compact_header=True %}
</div>
{% endif %}
{% endfor %}
</div>
{% if videos_archive_url %}
<div class="video-preview-more">
<a href="{{ videos_archive_url }}" class="btn-ghost">More videos <span aria-hidden="true"></span></a>
</div>
{% endif %}
</div>
</section>
{% endif %}
@@ -0,0 +1,9 @@
<div class="about-screenshots-grid about-section-screenshots">
{% for item in items %}
{% if item.is_featured and item.image %}
{% include "partials/_linked_card_open.html" with url=item.url card_class="screenshot-item fade-in screenshot-item--featured" %}
<img src="{{ item.image.url }}" alt="{{ item.image_alt|default:item.title }}" loading="lazy" />
{% include "partials/_linked_card_close.html" with url=item.url %}
{% endif %}
{% endfor %}
</div>
@@ -0,0 +1,20 @@
{% if item.image %}
{% include "partials/_linked_card_open.html" with url=item.url card_class="supporter-card" %}
<div class="supporter-logo">
<img src="{{ item.image.url }}" alt="{{ item.image_alt|default:item.title }} logo" loading="lazy" />
</div>
<div class="supporter-body">
{% if item.icon %}<div class="feature-icon feature-icon--inline" aria-hidden="true">{{ item.icon }}</div>{% endif %}
{% if item.badge %}<div class="standard-badge">{{ item.badge }}</div>{% endif %}
{% if item.title %}<h3 class="supporter-name">{{ item.title }}</h3>{% endif %}
{% if item.content %}<p class="supporter-desc" data-readmore="88">{{ item.content }}</p>{% endif %}
</div>
{% include "partials/_linked_card_close.html" with url=item.url %}
{% else %}
{% include "partials/_linked_card_open.html" with url=item.url card_class="feature-card" %}
{% if item.icon %}<div class="feature-icon" aria-hidden="true">{{ item.icon }}</div>{% endif %}
{% if item.badge %}<div class="standard-badge">{{ item.badge }}</div>{% endif %}
{% if item.title %}<h3 class="feature-title">{{ item.title }}</h3>{% endif %}
{% if item.content %}<p class="feature-desc" data-readmore="88">{{ item.content }}</p>{% endif %}
{% include "partials/_linked_card_close.html" with url=item.url %}
{% endif %}
+55
View File
@@ -0,0 +1,55 @@
{% if video.has_video %}
{% if video.video_styled_background %}
<div class="video-panel video-panel--styled fade-in">
<div class="video-panel-ambient" aria-hidden="true">
<div class="video-panel-blob video-panel-blob--1"></div>
<div class="video-panel-blob video-panel-blob--2"></div>
<div class="video-panel-blob video-panel-blob--3"></div>
</div>
<div class="video-panel-inner">
{% if video.badge or video.title %}
<div class="video-panel-header">
<div class="video-panel-heading">
{% if video.badge %}<div class="section-badge">{{ video.badge }}</div>{% endif %}
{% if video.title %}
<h2 class="video-panel-title" id="video-heading-{{ video.pk }}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
{{ video.title }}
</h2>
{% else %}
<h2 class="sr-only" id="video-heading-{{ video.pk }}">Video</h2>
{% endif %}
</div>
</div>
{% else %}
<h2 class="sr-only" id="video-heading-{{ video.pk }}">Video</h2>
{% endif %}
{% if video.description %}
<div class="rich-content section-desc video-panel-desc">{{ video.rendered_description }}</div>
{% elif video.content %}
<div class="rich-content section-desc video-panel-desc">{{ video.rendered_content }}</div>
{% endif %}
{% include "partials/_video_player.html" with video=video %}
</div>
</div>
{% else %}
{% if video.badge or video.title %}
<div class="section-header{% if compact_header %} product-video-header{% endif %}">
{% if video.badge %}<div class="section-badge">{{ video.badge }}</div>{% endif %}
{% if video.title %}
<h2 class="section-title" id="video-heading-{{ video.pk }}">{{ video.title }}</h2>
{% else %}
<h2 class="sr-only" id="video-heading-{{ video.pk }}">Video</h2>
{% endif %}
</div>
{% endif %}
{% if video.description %}
<div class="rich-content section-desc">{{ video.rendered_description }}</div>
{% elif video.content %}
<div class="rich-content section-desc">{{ video.rendered_content }}</div>
{% endif %}
{% include "partials/_video_player.html" with video=video %}
{% endif %}
{% endif %}
+26
View File
@@ -0,0 +1,26 @@
{% if video.has_video %}
<div class="video-block {{ video.video_size_class }} {{ video.video_aspect_class }} fade-in">
<div class="video-block-inner glass-card">
{% if video.video_source == "upload" and video.video_file %}
<video
controls
playsinline
preload="metadata"
{% if video.video_poster %}poster="{{ video.video_poster.url }}"{% endif %}
title="{{ video.title|default:'Video' }}"
>
<source src="{{ video.video_file.url }}" />
</video>
{% elif video.youtube_embed_url %}
<iframe
src="{{ video.youtube_embed_url }}"
title="{{ video.title|default:'Video' }}"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
loading="lazy"
referrerpolicy="strict-origin-when-cross-origin"
></iframe>
{% endif %}
</div>
</div>
{% endif %}
+7
View File
@@ -0,0 +1,7 @@
{% if section.has_video %}
<section class="section video-section" aria-labelledby="video-heading-{{ section.pk }}">
<div class="container">
{% include "partials/_video_block.html" with video=section %}
</div>
</section>
{% endif %}
@@ -0,0 +1,34 @@
{% 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>
@@ -0,0 +1,10 @@
{% 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 %}
+20 -52
View File
@@ -2,55 +2,25 @@
{% if show_releases_section %} {% if show_releases_section %}
{% if distribution_installable %} {% if distribution_installable %}
<section class="sub-downloads fade-in" aria-labelledby="sub-dl-heading"> <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"> <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"> <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"/> <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> </svg>
Downloads Downloads
</h2> </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>
<div class="sub-downloads-channel"> {% if featured_version %}
{% if featured_version.is_featured_stable %} {% include "products/_installable_download_channel.html" with channel=featured_channel %}
<span class="release-channel-label release-channel-label--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>
Stable
</span>
{% endif %} {% endif %}
{% if featured_install_cells %}
<div class="sub-downloads-grid"> {% for channel in inline_download_channels %}
{% for cell in featured_install_cells %} {% include "products/_installable_download_channel.html" %}
<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">v{{ featured_version.version }}</span>
<a href="{{ cell.url }}" class="btn-primary btn--sm"{% if cell.label == "Source code" %} target="_blank" rel="noopener noreferrer"{% endif %}>{% if cell.label == "Source code" %}Source{% else %}Download{% endif %}</a>
</div>
{% endfor %} {% endfor %}
</div>
{% endif %}
</div>
{% if featured_version.package_resource_url %}
<p class="sub-install-extra-resource">
<a href="{{ featured_version.package_resource_url }}" target="_blank" rel="noopener noreferrer">Package resource</a>
</p>
{% endif %}
{% if featured_version.release_notes %}
<p class="sub-release-notes">{{ featured_version.release_notes }}</p>
{% endif %}
{% if show_older_versions_link %} {% if show_older_versions_link %}
<p class="sub-older-versions-inner"> <p class="sub-older-versions-inner">
@@ -61,6 +31,7 @@
{% elif distribution_package %} {% elif distribution_package %}
<section class="sub-downloads sub-downloads--package fade-in" aria-labelledby="sub-resources-heading"> <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"> <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"> <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="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
@@ -68,26 +39,23 @@
</svg> </svg>
Resources Resources
</h2> </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"> <ul class="pkg-version-list" role="list">
{% for ver in package_versions %} {% for ver in package_versions %}
<li class="pkg-version-row"> <li class="pkg-version-row">
<div class="pkg-version-meta"> <div class="pkg-version-meta">
<span class="download-version">v{{ ver.version }}</span> <span class="download-version">{{ ver.version }}</span>
{% if ver.is_featured_stable %} {% include "products/_release_channel_badge.html" with version=ver %}
<span class="release-channel-label release-channel-label--stable pkg-stable-inline">
<svg width="10" height="10" 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>
Stable
</span>
{% endif %}
</div> </div>
<div class="pkg-version-actions"> <div class="pkg-version-actions">
{% if ver.package_resource_url %} {% if ver.package_resource_url %}
<a href="{{ ver.package_resource_url }}" class="btn-primary btn--sm" target="_blank" rel="noopener noreferrer">Open</a> <a href="{{ ver.package_resource_url }}" class="btn-primary btn--sm" target="_blank" rel="noopener noreferrer">{{ package_resource_button_text }}</a>
{% endif %} {% endif %}
{% if ver.source_code_url %} {% if ver.resolved_source_code_url %}
<a href="{{ ver.source_code_url }}" class="btn-ghost btn--sm" target="_blank" rel="noopener noreferrer">Source</a> <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 %} {% endif %}
</div> </div>
{% if ver.release_notes %} {% if ver.release_notes %}
+11 -5
View File
@@ -15,17 +15,20 @@
<tbody> <tbody>
{% for row in archive_rows_installable %} {% for row in archive_rows_installable %}
<tr> <tr>
<th scope="row">{{ row.version_obj.version }}</th> <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 %} {% for cell in row.cells %}
<td> <td>
{% if cell.url %} {% if cell.url %}
{% if cell.icon %} {% if cell.icon %}
<a href="{{ cell.url }}" class="versions-table-link"> <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" /> <img src="{% static cell.icon %}" alt="" width="22" height="22" class="versions-table-icon" aria-hidden="true" />
<span class="sr-only">{{ cell.label }}</span> <span class="sr-only">{{ cell.label }}</span>
</a> </a>
{% else %} {% else %}
<a href="{{ cell.url }}" class="versions-table-text-link" rel="noopener noreferrer">Source</a> <a href="{{ cell.url }}" class="versions-table-text-link"{% if cell.external %} target="_blank" rel="noopener noreferrer"{% endif %}>Source</a>
{% endif %} {% endif %}
{% else %} {% else %}
<span class="versions-na"></span> <span class="versions-na"></span>
@@ -50,9 +53,12 @@
{% for ver in archive_versions %} {% for ver in archive_versions %}
<li class="versions-package-card glass-card fade-in"> <li class="versions-package-card glass-card fade-in">
<div class="versions-package-head"> <div class="versions-package-head">
<span class="download-version">v{{ ver.version }}</span> <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 %} {% if ver.package_resource_url %}
<a href="{{ ver.package_resource_url }}" class="btn-ghost btn--sm" target="_blank" rel="noopener noreferrer">Open</a> <a href="{{ ver.package_resource_url }}" class="btn-ghost btn--sm" target="_blank" rel="noopener noreferrer">{{ package_resource_button_text }}</a>
{% endif %} {% endif %}
</div> </div>
{% if ver.release_notes %} {% if ver.release_notes %}
+3 -1
View File
@@ -39,7 +39,7 @@
{% if main_product.image %} {% if main_product.image %}
<img src="{{ main_product.image.url }}" alt="{{ main_product.name }}" loading="lazy" /> <img src="{{ main_product.image.url }}" alt="{{ main_product.name }}" loading="lazy" />
{% else %} {% else %}
<img src="{% static 'images/logo.png' %}" alt="{{ main_product.name }} logo" loading="lazy" class="product-logo-img" /> <img src="{% static 'images/com2care-logo.svg' %}" alt="{{ main_product.name }} logo" loading="lazy" class="product-logo-img" />
{% endif %} {% endif %}
</div> </div>
<div class="product-detail-text"> <div class="product-detail-text">
@@ -53,6 +53,8 @@
</div> </div>
</section> </section>
{% include "partials/_page_videos.html" with videos=product_videos %}
{% if articles %} {% if articles %}
<section class="section" aria-label="Product articles"> <section class="section" aria-label="Product articles">
<div class="container"> <div class="container">
+1 -1
View File
@@ -2,7 +2,7 @@
{% load static %} {% load static %}
{% block title %}Products{% endblock %} {% block title %}Products{% endblock %}
{% block meta_description %}Explore Radiuma's suite of medical imaging and radiomics software products.{% endblock %} {% block meta_description %}Explore com2care tools for collaborative medical imaging, radiomics, reproducible workflows, and research communication.{% endblock %}
{% block content %} {% block content %}
+2
View File
@@ -49,6 +49,8 @@
{% include "products/_releases_section.html" %} {% include "products/_releases_section.html" %}
{% include "partials/_page_videos.html" with videos=product_videos %}
{% include "products/_article_list.html" %} {% include "products/_article_list.html" %}
</main> </main>

Some files were not shown because too many files have changed in this diff Show More