Compare commits
10 Commits
f08d0aacc0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fc4282f0f | |||
| 04b6e9049e | |||
| 7cddc03a57 | |||
| 33b3477176 | |||
| cbc49cd8ae | |||
| 76185c7434 | |||
| de2789bd99 | |||
| 7726b0b6ec | |||
| 52516d5a5a | |||
| cacf1ba77b |
@@ -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
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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.style.SUCCESS(
|
||||||
|
f"Admin '{username}' credentials synchronized from the environment."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
self.style.WARNING(
|
self.style.SUCCESS(
|
||||||
"DJANGO_SUPERUSER_PASSWORD is not set — skipping superuser creation."
|
f"Admin '{username}' already exists; its password was preserved."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if User.objects.filter(username=username).exists():
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(f"Superuser '{username}' already exists — skipping.")
|
|
||||||
)
|
|
||||||
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.")
|
||||||
@@ -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"
|
contact, created = SiteContact.objects.get_or_create(pk=1, defaults=data)
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if not created:
|
if not created:
|
||||||
updates = {
|
self._update_instance(contact, data)
|
||||||
"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():
|
|
||||||
setattr(contact, field, value)
|
|
||||||
contact.save()
|
|
||||||
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'))]),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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/'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)'}"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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"}))
|
||||||
@@ -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>")
|
||||||
@@ -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}",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",)}),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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["show_releases_section"] = True
|
||||||
context["featured_install_cells"] = featured.install_urls_for_specs(specs)
|
|
||||||
context["show_releases_section"] = True
|
inline_blocks = []
|
||||||
older_list = list(
|
for ver in active_versions.order_by("order", "pk"):
|
||||||
active_versions.exclude(pk=featured.pk).order_by("order", "pk")
|
if featured and ver.pk == featured.pk:
|
||||||
if featured
|
continue
|
||||||
else active_versions.order_by("order", "pk")
|
if not ver.show_on_product_page:
|
||||||
)
|
continue
|
||||||
context["show_older_versions_link"] = len(older_list) > 0
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .development import * # noqa: F401, F403
|
||||||
|
|
||||||
|
DATABASES["default"]["CONN_MAX_AGE"] = 0 # noqa: F405
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
|
db:
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build: .
|
build: .
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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..."
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import sys
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development")
|
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")
|
||||||
try:
|
try:
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 291 KiB |
@@ -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 |
@@ -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 |
|
Before Width: | Height: | Size: 454 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 834 B |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 47 KiB |
@@ -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,38 +95,101 @@ 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('click', (e) => {
|
||||||
|
if (!isMobileNav()) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const isOpen = productsItem.classList.toggle('open');
|
||||||
|
trigger.setAttribute('aria-expanded', String(isOpen));
|
||||||
|
});
|
||||||
|
|
||||||
trigger.addEventListener('keydown', (e) => {
|
trigger.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (isMobileNav()) {
|
||||||
|
const isOpen = productsItem.classList.toggle('open');
|
||||||
|
trigger.setAttribute('aria-expanded', String(isOpen));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const isOpen = productsItem.classList.toggle('open');
|
const isOpen = productsItem.classList.toggle('open');
|
||||||
|
if (isOpen) {
|
||||||
|
openMenu();
|
||||||
|
} else {
|
||||||
|
cancelClose();
|
||||||
|
beginClose();
|
||||||
|
}
|
||||||
trigger.setAttribute('aria-expanded', String(isOpen));
|
trigger.setAttribute('aria-expanded', String(isOpen));
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
productsItem.classList.remove('open');
|
if (isMobileNav()) {
|
||||||
trigger.setAttribute('aria-expanded', 'false');
|
productsItem.classList.remove('open');
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function initFAQAccordion() {
|
function initFAQAccordion() {
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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">
|
||||||
© {% now "Y" %} Radiuma. All rights reserved.
|
© {% 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>
|
|
||||||
& <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 %}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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">
|
||||||
<h2 class="sub-downloads-title" id="sub-dl-heading">
|
<div class="sub-downloads-header">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
<h2 class="sub-downloads-title" id="sub-dl-heading">
|
||||||
<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 width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
</svg>
|
<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"/>
|
||||||
Downloads
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="sub-downloads-channel">
|
|
||||||
{% if featured_version.is_featured_stable %}
|
|
||||||
<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>
|
</svg>
|
||||||
Stable
|
Downloads
|
||||||
</span>
|
</h2>
|
||||||
{% endif %}
|
{% if downloads_section_link %}
|
||||||
{% if featured_install_cells %}
|
<a href="{{ downloads_section_link.url }}" class="sub-downloads-corner-link link-arrow" target="_blank" rel="noopener noreferrer">{{ downloads_section_link.text }}</a>
|
||||||
<div class="sub-downloads-grid">
|
|
||||||
{% for cell in featured_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">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 %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if featured_version.package_resource_url %}
|
{% if featured_version %}
|
||||||
<p class="sub-install-extra-resource">
|
{% include "products/_installable_download_channel.html" with channel=featured_channel %}
|
||||||
<a href="{{ featured_version.package_resource_url }}" target="_blank" rel="noopener noreferrer">Package resource</a>
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if featured_version.release_notes %}
|
{% for channel in inline_download_channels %}
|
||||||
<p class="sub-release-notes">{{ featured_version.release_notes }}</p>
|
{% include "products/_installable_download_channel.html" %}
|
||||||
{% endif %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if show_older_versions_link %}
|
{% if show_older_versions_link %}
|
||||||
<p class="sub-older-versions-inner">
|
<p class="sub-older-versions-inner">
|
||||||
@@ -61,33 +31,31 @@
|
|||||||
|
|
||||||
{% 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">
|
||||||
<h2 class="sub-downloads-title" id="sub-resources-heading">
|
<div class="sub-downloads-header">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
<h2 class="sub-downloads-title" id="sub-resources-heading">
|
||||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-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"/>
|
||||||
</svg>
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||||
Resources
|
</svg>
|
||||||
</h2>
|
Resources
|
||||||
|
</h2>
|
||||||
|
{% if downloads_section_link %}
|
||||||
|
<a href="{{ downloads_section_link.url }}" class="sub-downloads-corner-link link-arrow" target="_blank" rel="noopener noreferrer">{{ downloads_section_link.text }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
<ul class="pkg-version-list" role="list">
|
<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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||