initial commit

This commit is contained in:
mohamad
2026-06-21 17:54:19 +03:30
commit 20d6df3b27
191 changed files with 14362 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
.git
.gitignore
.env
.env.local
.venv
venv/
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
htmlcov/
.coverage
media/
staticfiles/
README.md
docs/
+17
View File
@@ -0,0 +1,17 @@
DJANGO_SETTINGS_MODULE=config.settings.production
DJANGO_SECRET_KEY=uqgb2wi9@1dr8alhhx$rp_tx!%_en$k7w6yjbu7wz-qr3$&3-w
DEBUG=False
ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0,tecvico.com,www.tecvico.com
CSRF_TRUSTED_ORIGINS=localhost,127.0.0.1,0.0.0.0,tecvico.com,www.tecvico.com
POSTGRES_DB=tecvico
POSTGRES_USER=tecvico_user
POSTGRES_PASSWORD=eS4_WYJH97gywyoHjP6v
POSTGRES_HOST=db
POSTGRES_PORT=5432
SECURE_SSL_REDIRECT=False
DJANGO_SUPERUSER_USERNAME=admin
DJANGO_SUPERUSER_EMAIL=admin@yourdomain.com
DJANGO_SUPERUSER_PASSWORD=bS7_U_uRTmivj7W-lCR5
+51
View File
@@ -0,0 +1,51 @@
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
dist/
*.egg-info/
.installed.cfg
*.egg
pip-log.txt
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
.pytest_cache/
nosetests.xml
coverage.xml
*.cover
*.log
local_settings.py
db.sqlite3
instance/
.scrapy
docs/_build/
__pypackages__/
celerybeat-schedule
celerybeat.pid
.env
.env.local
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
.mypy_cache/
.dmypy.json
media/
private_uploads/
staticfiles/
*.DS_Store
+29
View File
@@ -0,0 +1,29 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONFAULTHANDLER=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
DJANGO_SETTINGS_MODULE=config.settings.production
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libpq-dev \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 8000
ENTRYPOINT ["/entrypoint.sh"]
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-"]
+278
View File
@@ -0,0 +1,278 @@
# Tecvico Website
Django MVT informational website for **Tecvico**, showcasing products and solutions from [tecvico.com](https://tecvico.com/).
## Tech Stack
| Layer | Technology |
|---|---|
| Backend | Django 5.1 (MVT) |
| Database | PostgreSQL 16 |
| Static files | WhiteNoise (with Brotli compression) |
| Application server | Gunicorn |
| Containerization | Docker + Docker Compose |
| Frontend | Vanilla HTML/CSS/JS (no framework) |
## Project Structure
```
tecvico_website/
├── config/ # Django project configuration
│ └── settings/
│ ├── base.py # Shared settings
│ ├── development.py # Dev settings (DEBUG=True, dotenv)
│ └── production.py # Production settings (security headers)
├── apps/
│ ├── core/ # Context processors, management commands
│ │ └── management/commands/seed_content.py
│ ├── products/ # MainProduct, SubProduct, Article, ArticleSection
│ └── pages/ # FAQEntry, DownloadItem; static pages
├── templates/ # Global templates
│ ├── base.html
│ ├── partials/
│ └── pages/ & products/
├── static/
│ ├── css/main.css # Full design system (Tecvico light theme)
│ └── js/main.js # Navbar, FAQ accordion, scroll effects
├── Dockerfile
├── docker-compose.yml # Production compose
├── docker-compose.override.yml # Development compose overrides
└── entrypoint.sh # DB wait + migrate on container start
```
## URL Map
| URL | View | Description |
|---|---|---|
| `/` | `HomeView` | Landing page |
| `/about/` | `AboutView` | About Tecvico |
| `/products/` | `ProductOverviewView` | All main products |
| `/products/<main-slug>/` | `MainProductDetailView` | Main product + sub-products |
| `/products/<main-slug>/<sub-slug>/` | `SubProductDetailView` | Sub-product + articles |
| `/downloads/` | `DownloadsView` | Download items by platform |
| `/faq/` | `FAQView` | FAQ entries |
| `/contact/` | `ContactView` | Contact info |
| `/admin/` | Django Admin | Admin panel |
## 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
git clone <repo-url> tecvico_website
cd tecvico_website
cp .env.example .env
```
Edit `.env` and set at minimum:
```
DJANGO_SECRET_KEY=your-very-secure-random-key
POSTGRES_PASSWORD=choose-a-strong-password
```
### 2. Start services (development mode)
The `docker-compose.override.yml` automatically activates when you run `docker compose up`, mounting the source code and using the development settings.
```bash
docker compose up --build
```
The app is available at **http://localhost:8000**
### 3. Create a superuser
```bash
docker compose exec web python manage.py createsuperuser
```
### 4. Seed initial content
Populate the database with content scraped and adapted from visera.ca:
```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+
- PostgreSQL 14+
### 1. Set up virtual environment
```bash
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements-dev.txt
```
### 2. Configure environment
```bash
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 seed_content
python manage.py createsuperuser
```
### 5. Start development server
```bash
python manage.py runserver
```
---
## Running Tests
### With Django test runner
```bash
python manage.py test apps
```
### With pytest (requires `requirements-dev.txt`)
```bash
pytest
```
### With coverage report
```bash
coverage run -m pytest
coverage report -m
coverage html # Generates htmlcov/index.html
```
---
## Production Deployment
### 1. Build and start production containers
Remove `docker-compose.override.yml` (or don't override it) and pass production environment variables:
```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:
- **Tecvico** (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 Tecvico.
View File
View File
+92
View File
@@ -0,0 +1,92 @@
from django.contrib import admin
from django.http import HttpResponseRedirect
from django.urls import reverse
from .models import SiteBranding, SiteContact
@admin.register(SiteBranding)
class SiteBrandingAdmin(admin.ModelAdmin):
fieldsets = (
("Icon", {"fields": ("icon", "icon_alt")}),
(
"Sizes",
{
"fields": ("navbar_icon_size", "footer_icon_size"),
"description": "Square dimensions in pixels for each placement.",
},
),
(
"Appearance",
{
"fields": (
"object_fit",
"show_border",
"border_width",
"border_color",
),
},
),
)
def has_add_permission(self, request):
return not SiteBranding.objects.exists()
def has_delete_permission(self, request, obj=None):
return False
def changelist_view(self, request, extra_context=None):
branding = SiteBranding.objects.first()
if branding:
return HttpResponseRedirect(
reverse("admin:core_sitebranding_change", args=[branding.pk])
)
return super().changelist_view(request, extra_context)
@admin.register(SiteContact)
class SiteContactAdmin(admin.ModelAdmin):
fieldsets = (
(
"Email",
{
"fields": (
"support_email",
"email_card_title",
"email_card_description",
),
},
),
(
"Discord",
{
"fields": (
"discord_url",
"discord_label",
"discord_card_title",
"discord_card_description",
),
},
),
(
"Office address",
{
"fields": ("office_address", "office_card_title"),
"description": "Enter one address line per row.",
},
),
)
def has_add_permission(self, request):
return not SiteContact.objects.exists()
def has_delete_permission(self, request, obj=None):
return False
def changelist_view(self, request, extra_context=None):
contact = SiteContact.objects.first()
if contact:
return HttpResponseRedirect(
reverse("admin:core_sitecontact_change", args=[contact.pk])
)
return super().changelist_view(request, extra_context)
+30
View File
@@ -0,0 +1,30 @@
from django.utils.html import format_html
from apps.core.video import VIDEO_SOURCE_UPLOAD, VIDEO_SOURCE_YOUTUBE
def video_admin_preview(obj):
if not obj.has_video:
return "No video configured yet."
if obj.video_source == VIDEO_SOURCE_UPLOAD and obj.video_file:
if obj.video_poster:
return format_html(
'<video controls playsinline preload="metadata" style="max-width:100%;" poster="{}">'
'<source src="{}"></video>',
obj.video_poster.url,
obj.video_file.url,
)
return format_html(
'<video controls playsinline preload="metadata" style="max-width:100%;">'
'<source src="{}"></video>',
obj.video_file.url,
)
if obj.video_source == VIDEO_SOURCE_YOUTUBE and obj.youtube_embed_url:
return format_html(
'<iframe src="{}" title="Preview" width="480" height="270" '
'style="max-width:100%;border:0;" allowfullscreen loading="lazy"></iframe>',
obj.youtube_embed_url,
)
return "No video configured yet."
video_admin_preview.short_description = "Preview"
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.core"
verbose_name = "Core"
+41
View File
@@ -0,0 +1,41 @@
from django.db.models import Prefetch
from apps.core.models import SiteBranding, SiteContact
from apps.pages.models import CustomPage
from apps.products.models import MainProduct, SubProduct
def site_branding(request):
return {"site_branding": SiteBranding.load()}
def site_contact(request):
return {"site_contact": SiteContact.load()}
def navigation(request):
main_products = (
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")
)
all_footer_products = list(main_products[:5])
footer_main_products = all_footer_products[:4]
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 {
"nav_main_products": main_products,
"nav_custom_pages": nav_custom_pages,
"footer_main_products": footer_main_products,
"footer_main_products_has_more": footer_main_products_has_more,
}
View File
@@ -0,0 +1,32 @@
import os
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Create a superuser from environment variables if one does not already exist."
def handle(self, *args, **options):
User = get_user_model()
username = os.environ.get("DJANGO_SUPERUSER_USERNAME", "admin")
email = os.environ.get("DJANGO_SUPERUSER_EMAIL", "admin@tecvico.com")
password = os.environ.get("DJANGO_SUPERUSER_PASSWORD")
if not password:
self.stdout.write(
self.style.WARNING(
"DJANGO_SUPERUSER_PASSWORD is not set — skipping superuser creation."
)
)
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)
self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' created successfully."))
@@ -0,0 +1,732 @@
from pathlib import Path
from django.conf import settings
from django.core.files import File
from django.core.management.base import BaseCommand
from django.db import transaction
from apps.core.models import SiteBranding, SiteContact
from apps.pages.models import DownloadItem, FAQEntry, HeroSection, HomepageSection, HomepageSectionItem
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct
MAIN_PRODUCTS = [
{
"name": "Tecvico",
"slug": "tecvico",
"short_description": "Visualized & Standardized Environment for Radiomics Analysis",
"description": (
"Tecvico is a free, open-source software specialized for visualization, "
"processing, segmentation, registration, fusion and analysis of medical and "
"biomedical images, including radiomics and machine learning analysis. "
"Tecvico is a major, entirely-revamped upgrade to the original SERA "
"(Matlab-based), now built on Python for broader accessibility and community "
"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,
"show_on_homepage": True,
"homepage_order": 1,
"sub_products": [
{
"name": "Image Processing",
"slug": "image-processing",
"short_description": "Standardized filtering, registration, and fusion techniques",
"description": (
"Advanced image processing capabilities including standardized filtering "
"techniques compliant with IBSI 2.0, image registration, fusion, and "
"Standardized Uptake Value (SUV) conversion. Tecvico employs popular "
"image processing algorithms to create end-to-end standardized workflows "
"for consistent, reproducible research outcomes."
),
"order": 1,
"articles": [
{
"title": "Image Filtering Techniques",
"description": (
"Tecvico implements a comprehensive set of image filtering techniques "
"fully standardized against the Image Biomarker Standardization "
"Initiative (IBSI) phase 2. These filters enable reproducible "
"preprocessing across institutions and studies."
),
"order": 1,
"sections": [
{"title": "Standardization", "value": "IBSI 2.0 compliant", "order": 1},
{
"title": "Available Filters",
"value": "Mean, Gaussian, Laplacian of Gaussian (LoG), Laws kernels, Gabor, Wavelets (PyWavelets), Log-Sigma",
"order": 2,
},
{"title": "Author", "value": "Tecvico R&D Team", "order": 3},
],
},
{
"title": "Image Registration & Fusion",
"description": (
"Tecvico provides robust image registration and fusion methods, "
"enabling multi-modal image alignment for PET/CT, PET/MRI, and "
"other combined modality studies. Standardized Uptake Value (SUV) "
"conversion is also supported."
),
"order": 2,
"sections": [
{"title": "Registration Methods", "value": "Rigid, Affine, Deformable (B-spline)", "order": 1},
{"title": "Fusion Techniques", "value": "Overlay, weighted average, multi-modal blending", "order": 2},
{"title": "Special Feature", "value": "Standardized Uptake Value (SUV) conversion", "order": 3},
],
},
],
},
{
"name": "Radiomics Features",
"slug": "radiomics-features",
"short_description": "IBSI 1.0 compliant handcrafted radiomic feature extraction",
"description": (
"Tecvico provides comprehensive handcrafted radiomic feature extraction "
"fully standardized by the Image Biomarker Standardization Initiative "
"(IBSI 1.0). Features are computed from segmented regions of interest "
"across multiple image modalities, enabling reproducible quantitative "
"imaging biomarker research."
),
"order": 2,
"articles": [
{
"title": "IBSI Compliant Feature Extraction",
"description": (
"Tecvico computes a comprehensive set of radiomic features "
"covering all IBSI 1.0 feature classes. Features are extracted "
"from segmented Regions of Interest (ROIs) and are fully "
"reproducible across different platforms and institutions."
),
"order": 1,
"sections": [
{"title": "Standardization", "value": "IBSI 1.0 compliant", "order": 1},
{
"title": "Feature Classes",
"value": "Shape (3D & 2D), First-order Statistics, GLCM, GLRLM, GLSZM, GLDM, NGTDM",
"order": 2,
},
{"title": "Output Formats", "value": "CSV, JSON, Excel", "order": 3},
{"title": "Reference", "value": "Zwanenburg et al. (2020), Radiology", "order": 4},
],
},
],
},
{
"name": "Medical Image Visualization",
"slug": "medical-image-visualization",
"short_description": "Professional multi-modality medical image viewer",
"description": (
"Tecvico includes a professional medical image viewer that supports "
"multiple imaging modalities and file formats. The viewer provides "
"comfortable, intuitive controls for slice navigation, windowing, "
"zoom, and annotation, suitable for radiation oncologists, radiologists, "
"physicists, and data scientists."
),
"order": 3,
"articles": [
{
"title": "Multi-Modal Image Viewer",
"description": (
"The integrated viewer supports simultaneous display of multiple "
"image modalities with linked cursors, adjustable window/level, "
"and overlay capabilities. RT struct contours are rendered "
"directly over the underlying images."
),
"order": 1,
"sections": [
{"title": "Supported Modalities", "value": "CT, MRI, PET, SPECT, CBCT", "order": 1},
{"title": "File Formats", "value": "DICOM, NIFTI (.nii, .nii.gz), NRRD, MHA, NII", "order": 2},
{"title": "Special Support", "value": "RT Struct, RT Dose, RT Plan visualization", "order": 3},
],
},
],
},
{
"name": "Format Conversion",
"slug": "format-conversion",
"short_description": "Professional converter for medical imaging file formats",
"description": (
"Tecvico provides a professional image format converter supporting all "
"major medical imaging standards. Seamlessly convert between DICOM, "
"NIFTI, NRRD, MHA, and other formats without loss of spatial metadata "
"or patient information integrity."
),
"order": 4,
"articles": [
{
"title": "Medical Image Format Converter",
"description": (
"The built-in converter handles complex DICOM series reconstruction, "
"preserving spatial orientation, voxel spacing, and relevant metadata "
"throughout conversion. Batch conversion is supported for large "
"research datasets."
),
"order": 1,
"sections": [
{"title": "Input Formats", "value": "DICOM (all SOP classes), NIFTI, NRRD, NII, MHA, MetaImage", "order": 1},
{"title": "Output Formats", "value": "NIFTI (.nii.gz), NRRD, MHA, NII", "order": 2},
{"title": "Batch Processing", "value": "Supported — process entire datasets automatically", "order": 3},
],
},
],
},
{
"name": "Workflow Management",
"slug": "workflow-management",
"short_description": "Reproducible research workflow creation and sharing",
"description": (
"Tecvico's workflow management system allows researchers to design, save, "
"share, and reuse analysis pipelines. Workflows connect individual "
"processing steps — from image loading and preprocessing to feature "
"extraction and machine learning — into reproducible, shareable sequences "
"that ensure consistency across studies and institutions."
),
"order": 5,
"articles": [
{
"title": "Reproducible Research Workflows",
"description": (
"Create end-to-end analysis pipelines by visually connecting "
"processing nodes. Each workflow can be exported, shared with "
"collaborators, and re-executed to reproduce results on new datasets."
),
"order": 1,
"sections": [
{"title": "Key Benefit", "value": "Usability, Reusability and Reproducibility (URR)", "order": 1},
{"title": "Collaboration", "value": "Share workflows, datasets, and results with research teams", "order": 2},
{"title": "Compatibility", "value": "Works with all supported image modalities and feature extractors", "order": 3},
],
},
],
},
],
},
]
FAQ_ENTRIES = [
{
"question": "What is the Tecvico license?",
"answer": (
"Tecvico is free and open-source for research purposes.\n\n"
"License: CC BY-NC-SA (Creative Commons Attribution-NonCommercial-ShareAlike). "
"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,
},
{
"question": "How do I cite Tecvico in my research?",
"answer": (
"Please cite the following reference if you publish results obtained with "
"the help of Tecvico:\n\n"
"M. R. Salmanpour, I. Shiri, M. Hosseinzadeh, H. Zaidi, S. Ashrafinia, "
"M. Oveisi, A. Rahmim. Tecvico: Visualized & Standardized Environment for "
"Radiomics Analysis — A Shareable, Executable, and Reproducible Workflow "
"Generator. Proc. IEEE Medical Imaging Conference, 2023."
),
"order": 2,
},
{
"question": "Which operating systems does Tecvico support?",
"answer": (
"Tecvico currently fully supports Windows 10 and above (64-bit). "
"New versions to support macOS and Linux systems are under active development "
"and coming soon. Follow our Discord or check each product module page for updates."
),
"order": 3,
},
{
"question": "Can I install a new version over an existing installation?",
"answer": (
"Yes, you can install the new version without removing the previous one. "
"However, if you encounter any problems after upgrading, we recommend "
"uninstalling the old version first, then performing a clean installation "
"of the new release."
),
"order": 4,
},
{
"question": "Is Tecvico suitable for clinical use?",
"answer": (
"Tecvico is designed and intended exclusively for research purposes. "
"It is not certified for clinical diagnostic use. Always consult with "
"qualified medical professionals for clinical decisions."
),
"order": 5,
},
{
"question": "Where can I get support or report issues?",
"answer": (
"Support is available via email and through our community Discord server "
"(see the Contact page for current details). For bug reports and feature "
"requests, please use the Discord forum or contact us directly by email."
),
"order": 6,
},
]
HOMEPAGE_SECTIONS = [
{
"section_type": HomepageSection.TYPE_FEATURES,
"badge": "Capabilities",
"title": "Important Features",
"description": "Comprehensive tools for medical imaging research, standardized and reproducible.",
"order": 1,
"items": [
{"icon": "⚗️", "title": "Image Filtering", "content": "Standardized image filtering techniques compliant with IBSI 2.0 guidelines.", "order": 1},
{"icon": "🖥️", "title": "Professional Viewer", "content": "Comfortable, professional medical image viewer with multi-modality support.", "order": 2},
{"icon": "📊", "title": "Radiomics Features", "content": "Handcrafted radiomics feature generation standardized by IBSI 1.0.", "order": 3},
{"icon": "🔄", "title": "Format Support", "content": "NIFTI, DICOM, NRRD, and more — comprehensive multi-format support.", "order": 4},
{"icon": "🗂️", "title": "Image Registration", "content": "Advanced image registration, fusion, and standardized SUV conversion.", "order": 5},
{"icon": "🔬", "title": "RT Struct Support", "content": "Full RT struct support for radiation oncology workflows and research.", "order": 6},
],
},
{
"section_type": HomepageSection.TYPE_SCREENSHOTS,
"badge": "Gallery",
"title": "See Tecvico in Action",
"description": "Explore Tecvico's powerful interface, workflow builder, and multi-modal image viewer.",
"order": 2,
"items": [],
},
{
"section_type": HomepageSection.TYPE_PRODUCTS,
"badge": "Our Software",
"title": "Products",
"description": "Explore our suite of medical imaging and radiomics tools.",
"order": 3,
"items": [],
},
{
"section_type": HomepageSection.TYPE_PROBLEMS,
"badge": "Value Proposition",
"title": "What Problems Does Tecvico Solve?",
"description": "",
"order": 4,
"items": [
{"icon": "01", "title": "Accessibility", "content": "Tecvico 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": "Tecvico 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": "Tecvico 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},
],
},
{
"section_type": HomepageSection.TYPE_PROJECTS,
"title": "Our Journey in the Realm of",
"title_highlight": "Outstanding Projects",
"description": (
"Explore our standout projects here and immerse yourself in our journey "
"through the world of innovation and development."
),
"order": 5,
"items": [
{
"title": "Revolutionizing Radiomics Analysis and Medical Image Visualization",
"content": (
"Visera is a free, open-source software specialized for visualization, processing, "
"segmentation, registration, fusion and analysis of medical / biomedical images, "
"including radiomics and machine learning analysis."
),
"tags": "Web Development, Publication",
"project_status": HomepageSectionItem.STATUS_NEW,
"url": "https://visera.ca/",
"image_path": "projects/research2.png",
"image_alt": "Visera medical imaging software",
"order": 1,
},
{
"title": "Automatic Segmentation of Head and Neck Cancer using Fusion ...",
"content": (
"People with the below expertise are able to apply for this project: "
"1-The individual with enough experience ..."
),
"tags": "Web Development, Publication",
"project_status": HomepageSectionItem.STATUS_ONGOING,
"image_path": "projects/project-frame-2.png",
"order": 2,
},
{
"title": "Predicting TNM Stage in Head and Neck Cancer using Multi-Modality Fusion ...",
"content": "People with the below expertise are able to apply for this project.",
"tags": "Web Development, Publication",
"project_status": HomepageSectionItem.STATUS_DONE,
"image_path": "projects/project-frame-3.png",
"order": 3,
},
{
"title": "Application of Deep Learning Techniques Coupled with fusion Models for ...",
"content": (
"People with the below expertise are able to apply for this project: "
"1-The individual with enough experience ..."
),
"tags": "Web Development, Publication",
"project_status": HomepageSectionItem.STATUS_NEW,
"image_path": "projects/project-frame-4.png",
"order": 4,
},
{
"title": "Identifying Reliable and Robust Tensor Radiomics Features in Lung Cancer",
"content": (
"Radiomics is a major frontier in medical image analysis, enabling the mining "
"of high-dimensional data from ..."
),
"tags": "Web Development, Publication",
"project_status": HomepageSectionItem.STATUS_ONGOING,
"image_path": "projects/project-frame-5.png",
"order": 5,
},
],
},
{
"section_type": HomepageSection.TYPE_EXPERIENCE,
"title": "Experience Leading",
"title_highlight": "the Way in Development",
"description": (
"Embark on a journey of accelerated product development, prioritizing stability, "
"security, and flexible technology choices.\n\n"
"Our commitment to crafting a distinctive user experience ensures your product "
"stands out with innovative design and seamless functionality."
),
"order": 6,
"items": [
{
"title": "Financial Benefits",
"content": (
"Maximize your financial gains with our solutions, offering cost-effective "
"strategies and optimized financial performance."
),
"image_path": "experience/financial-benefits.svg",
"order": 1,
},
{
"title": "24/7 Support",
"content": (
"Enjoy peace of mind with our round-the-clock support, ensuring assistance "
"and guidance whenever you need it, day or night."
),
"image_path": "experience/support.svg",
"order": 2,
},
{
"title": "Quality Assurance",
"content": (
"Rely on our commitment to quality assurance, where meticulous processes "
"guarantee the delivery of high-quality, error-free outcomes."
),
"image_path": "experience/quality-assurance.svg",
"order": 3,
},
{
"title": "International Workshop",
"content": (
"Engage in our international workshops, fostering collaboration and knowledge "
"exchange on a global scale for enhanced innovation and skill development."
),
"image_path": "experience/international-workshop.svg",
"order": 4,
},
],
},
{
"section_type": HomepageSection.TYPE_ABOUT_STRIP,
"badge": "Our Story",
"title": "More to Know",
"description": (
"Tecvico has been developing since 2021 by the Quantitative Radiomolecular Imaging "
"and Therapy (Qurit) lab & program at the University of British Columbia & "
"BC Cancer Research Institute, Vancouver, BC, Canada."
),
"link_text": "Learn More",
"link_url": "/about/",
"order": 7,
"items": [],
},
{
"section_type": HomepageSection.TYPE_SUPPORTERS,
"badge": "Acknowledgements",
"title": "Our Supporters",
"description": "Tecvico is made possible by the support of leading research institutions and organizations.",
"order": 8,
"items": [
{
"title": "University of British Columbia",
"content": "Faculty of Medicine and the Department of Integrative Oncology.",
"order": 1,
},
{
"title": "BC Cancer Research Institute",
"content": "Supporting cutting-edge radiomics and medical imaging research in Vancouver, BC.",
"order": 2,
},
],
},
]
DOWNLOAD_ITEMS = [
{
"name": "Tecvico Desktop",
"platform": "windows",
"version": "1.0.0",
"download_url": "https://github.com/tecvico/tecvico/releases/latest/download/Tecvico-Setup.exe",
"description": "Windows 10 and above (64-bit). Installer package.",
"is_active": True,
"order": 1,
},
{
"name": "Tecvico Desktop",
"platform": "macos",
"version": "Coming Soon",
"download_url": "#",
"description": "macOS version is under development.",
"is_active": False,
"order": 2,
},
{
"name": "Tecvico Desktop",
"platform": "linux",
"version": "Coming Soon",
"download_url": "#",
"description": "Linux version is under development.",
"is_active": False,
"order": 3,
},
]
class Command(BaseCommand):
help = "Seed the database with initial Tecvico website content"
def add_arguments(self, parser):
parser.add_argument(
"--flush",
action="store_true",
help="Delete all existing seed data before re-seeding",
)
@transaction.atomic
def handle(self, *args, **options):
if options["flush"]:
self.stdout.write("Flushing existing seed data...")
ArticleSection.objects.all().delete()
Article.objects.all().delete()
SubProduct.objects.all().delete()
MainProduct.objects.all().delete()
FAQEntry.objects.all().delete()
DownloadItem.objects.all().delete()
HomepageSectionItem.objects.all().delete()
HomepageSection.objects.all().delete()
HeroSection.objects.all().delete()
self._seed_products()
self._seed_faq()
self._seed_downloads()
self._seed_homepage_sections()
self._seed_hero()
self._seed_site_contact()
self._seed_site_branding()
self.stdout.write(self.style.SUCCESS("Content seeded successfully."))
def _seed_products(self):
for product_data in MAIN_PRODUCTS:
sub_products_data = product_data.pop("sub_products")
main_product, created = MainProduct.objects.get_or_create(
slug=product_data["slug"],
defaults=product_data,
)
if not created:
for field, value in product_data.items():
setattr(main_product, field, value)
main_product.save()
action = "Created" if created else "Updated"
self.stdout.write(f" {action} main product: {main_product.name}")
for sub_data in sub_products_data:
articles_data = sub_data.pop("articles")
sub_product, sub_created = SubProduct.objects.get_or_create(
main_product=main_product,
slug=sub_data["slug"],
defaults=sub_data,
)
if not sub_created:
for field, value in sub_data.items():
setattr(sub_product, field, value)
sub_product.save()
sub_action = "Created" if sub_created else "Updated"
self.stdout.write(f" {sub_action} sub-product: {sub_product.name}")
for article_data in articles_data:
sections_data = article_data.pop("sections")
article, art_created = Article.objects.get_or_create(
sub_product=sub_product,
title=article_data["title"],
defaults=article_data,
)
if not art_created:
for field, value in article_data.items():
setattr(article, field, value)
article.save()
art_action = "Created" if art_created else "Updated"
self.stdout.write(f" {art_action} article: {article.title}")
for section_data in sections_data:
section, _ = ArticleSection.objects.get_or_create(
article=article,
title=section_data["title"],
defaults=section_data,
)
def _seed_faq(self):
for entry_data in FAQ_ENTRIES:
faq, created = FAQEntry.objects.get_or_create(
question=entry_data["question"],
defaults=entry_data,
)
if not created:
for field, value in entry_data.items():
setattr(faq, field, value)
faq.save()
action = "Created" if created else "Updated"
self.stdout.write(f" {action} FAQ: {faq.question[:60]}...")
def _seed_homepage_sections(self):
for section_data in HOMEPAGE_SECTIONS:
items_data = section_data.pop("items")
section, created = HomepageSection.objects.get_or_create(
order=section_data["order"],
defaults=section_data,
)
if not created:
for field, value in section_data.items():
setattr(section, field, value)
section.is_active = True
section.save()
action = "Created" if created else "Updated"
self.stdout.write(f" {action} homepage section: {section}")
seed_titles = [item_data["title"] for item_data in items_data if item_data.get("title")]
if seed_titles:
section.items.exclude(title__in=seed_titles).delete()
else:
section.items.all().delete()
for item_data in items_data:
image_path = item_data.pop("image_path", None)
item, item_created = HomepageSectionItem.objects.get_or_create(
section=section,
title=item_data["title"],
defaults=item_data,
)
if not item_created:
for field, value in item_data.items():
setattr(item, field, value)
item.save()
if image_path:
self._attach_item_image(item, image_path)
def _attach_item_image(self, item, relative_path):
path = Path(settings.BASE_DIR) / "static" / "images" / "tecvico" / relative_path
if not path.exists():
self.stdout.write(self.style.WARNING(f" Missing image: {path}"))
return
if item.image and item.image.name.endswith(path.name):
return
with path.open("rb") as handle:
item.image.save(path.name, File(handle), save=True)
def _seed_downloads(self):
for item_data in DOWNLOAD_ITEMS:
item, created = DownloadItem.objects.get_or_create(
name=item_data["name"],
platform=item_data["platform"],
defaults=item_data,
)
if not created:
for field, value in item_data.items():
setattr(item, field, value)
item.save()
action = "Created" if created else "Updated"
self.stdout.write(f" {action} download: {item}")
def _seed_hero(self):
data = {
"badge": "Innovation, Advancement, Competition",
"title": "Advanced Solutions",
"title_highlight": "for Your Business Development",
"subtitle": "Welcome to a new era of commerce with us",
"description": (
"At Tecvico, we're a dynamic team challenging business norms. Our creativity and "
"expertise converge to provide innovative solutions, transforming enterprises."
),
"primary_cta_text": "Get started for free",
"primary_cta_url": "/products/",
"secondary_cta_text": "About Tecvico",
"secondary_cta_url": "/about/",
"image_alt": "Tecvico showcase",
}
hero = HeroSection.objects.first()
if hero is None:
HeroSection.objects.create(**data)
self.stdout.write(" Created hero section")
else:
for field, value in data.items():
if field == "image":
continue
setattr(hero, field, value)
hero.save()
self.stdout.write(" Updated hero section")
def _seed_site_contact(self):
contact, created = SiteContact.objects.get_or_create(
pk=1,
defaults={
"support_email": "pr@tecvico.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"
),
},
)
if not created:
updates = {
"support_email": "pr@tecvico.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")
def _seed_site_branding(self):
branding = SiteBranding.load()
if branding.icon:
branding.icon.delete(save=False)
branding.icon = None
branding.icon_alt = "Tecvico"
branding.navbar_icon_size = 40
branding.footer_icon_size = 48
branding.show_border = False
branding.save()
self.stdout.write(" Updated site branding")
@@ -0,0 +1,32 @@
# Generated by Django 5.0.2 on 2026-05-25 09:39
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='SiteBranding',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('icon', models.ImageField(blank=True, help_text='Logo shown in the site header and footer. Leave empty to use the default static icon.', null=True, upload_to='branding/')),
('icon_alt', models.CharField(blank=True, help_text='Alt text for the brand icon (decorative icons can stay empty).', max_length=200)),
('navbar_icon_size', models.PositiveSmallIntegerField(default=26, help_text='Width and height in pixels for the header icon.')),
('footer_icon_size', models.PositiveSmallIntegerField(default=34, help_text='Width and height in pixels for the footer icon.')),
('show_border', models.BooleanField(default=False, help_text='Draw a border around the brand icon.')),
('border_color', models.CharField(default='#c4b5fd', help_text='Border color as hex (e.g. #c4b5fd).', max_length=7)),
('border_width', models.PositiveSmallIntegerField(default=1, help_text='Border width in pixels.')),
('object_fit', models.CharField(choices=[('cover', 'Cover (fill square, may crop)'), ('contain', 'Contain (fit inside square)')], default='cover', max_length=10)),
],
options={
'verbose_name': 'Site Branding',
'verbose_name_plural': 'Site Branding',
},
),
]
+52
View File
@@ -0,0 +1,52 @@
# Generated by Django 5.0.2 on 2026-05-25 11:11
from django.db import migrations, models
def seed_site_contact(apps, schema_editor):
SiteContact = apps.get_model("core", "SiteContact")
SiteContact.objects.get_or_create(
pk=1,
defaults={
"support_email": "support@radiuma.com",
"discord_url": "https://discord.gg/9XxA6pV9hb",
"office_address": (
"BC Cancer Research Center\n"
"675 West 10th Ave, Office 6-112\n"
"Vancouver, BC, V5Z 1L3\n"
"Canada"
),
"email_card_description": "For direct software support:",
"discord_card_description": "Join for community support and announcements.",
},
)
class Migration(migrations.Migration):
dependencies = [
('core', '0001_site_branding'),
]
operations = [
migrations.CreateModel(
name='SiteContact',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('support_email', models.EmailField(blank=True, help_text='Shown in the footer and on the contact page. Hidden when empty.', max_length=254)),
('discord_url', models.URLField(blank=True, help_text='Discord invite link. Hidden when empty.')),
('discord_label', models.CharField(blank=True, help_text='Button label (e.g. Join Discord). Defaults to “Join Discord” when empty.', max_length=100)),
('office_address', models.TextField(blank=True, help_text='Physical address; one line per row. Hidden when empty.')),
('email_card_title', models.CharField(blank=True, help_text='Contact page email card heading. Defaults to “Email Support”.', max_length=200)),
('email_card_description', models.CharField(blank=True, help_text='Short text under the email card heading on the contact page.', max_length=500)),
('discord_card_title', models.CharField(blank=True, help_text='Contact page Discord card heading. Defaults to “Discord Community”.', max_length=200)),
('discord_card_description', models.CharField(blank=True, help_text='Short text under the Discord card heading on the contact page.', max_length=500)),
('office_card_title', models.CharField(blank=True, help_text='Contact page office card heading. Defaults to “Office”.', max_length=200)),
],
options={
'verbose_name': 'Site Contact',
'verbose_name_plural': 'Site Contact',
},
),
migrations.RunPython(seed_site_contact, migrations.RunPython.noop),
]
View File
+177
View File
@@ -0,0 +1,177 @@
from django.db import models
class SiteBranding(models.Model):
OBJECT_FIT_COVER = "cover"
OBJECT_FIT_CONTAIN = "contain"
OBJECT_FIT_CHOICES = [
(OBJECT_FIT_COVER, "Cover (fill square, may crop)"),
(OBJECT_FIT_CONTAIN, "Contain (fit inside square)"),
]
icon = models.ImageField(
upload_to="branding/",
blank=True,
null=True,
help_text="Logo shown in the site header and footer. Leave empty to use the default static icon.",
)
icon_alt = models.CharField(
max_length=200,
blank=True,
help_text="Alt text for the brand icon (decorative icons can stay empty).",
)
navbar_icon_size = models.PositiveSmallIntegerField(
default=26,
help_text="Width and height in pixels for the header icon.",
)
footer_icon_size = models.PositiveSmallIntegerField(
default=34,
help_text="Width and height in pixels for the footer icon.",
)
show_border = models.BooleanField(
default=False,
help_text="Draw a border around the brand icon.",
)
border_color = models.CharField(
max_length=7,
default="#3051ff",
help_text="Border color as hex (e.g. #c4b5fd).",
)
border_width = models.PositiveSmallIntegerField(
default=1,
help_text="Border width in pixels.",
)
object_fit = models.CharField(
max_length=10,
choices=OBJECT_FIT_CHOICES,
default=OBJECT_FIT_COVER,
)
class Meta:
verbose_name = "Site Branding"
verbose_name_plural = "Site Branding"
def __str__(self):
return "Site Branding"
@classmethod
def load(cls):
obj, _ = cls.objects.get_or_create(pk=1)
return obj
def icon_style(self, size_px):
parts = [
f"width:{size_px}px",
f"height:{size_px}px",
f"object-fit:{self.object_fit}",
]
if self.show_border:
parts.append(f"border:{self.border_width}px solid {self.border_color}")
return ";".join(parts)
@property
def navbar_icon_style(self):
return self.icon_style(self.navbar_icon_size)
@property
def footer_icon_style(self):
return self.icon_style(self.footer_icon_size)
class SiteContact(models.Model):
support_email = models.EmailField(
blank=True,
help_text="Shown in the footer and on the contact page. Hidden when empty.",
)
discord_url = models.URLField(
blank=True,
help_text="Discord invite link. Hidden when empty.",
)
discord_label = models.CharField(
max_length=100,
blank=True,
help_text="Button label (e.g. Join Discord). Defaults to “Join Discord” when empty.",
)
office_address = models.TextField(
blank=True,
help_text="Physical address; one line per row. Hidden when empty.",
)
email_card_title = models.CharField(
max_length=200,
blank=True,
help_text="Contact page email card heading. Defaults to “Email Support”.",
)
email_card_description = models.CharField(
max_length=500,
blank=True,
help_text="Short text under the email card heading on the contact page.",
)
discord_card_title = models.CharField(
max_length=200,
blank=True,
help_text="Contact page Discord card heading. Defaults to “Discord Community”.",
)
discord_card_description = models.CharField(
max_length=500,
blank=True,
help_text="Short text under the Discord card heading on the contact page.",
)
office_card_title = models.CharField(
max_length=200,
blank=True,
help_text="Contact page office card heading. Defaults to “Office”.",
)
class Meta:
verbose_name = "Site Contact"
verbose_name_plural = "Site Contact"
def __str__(self):
return "Site Contact"
@classmethod
def load(cls):
obj, _ = cls.objects.get_or_create(pk=1)
return obj
@property
def discord_label_display(self):
return self.discord_label.strip() or "Join Discord"
@property
def office_address_lines(self):
if not self.office_address.strip():
return []
return [line.strip() for line in self.office_address.splitlines() if line.strip()]
@property
def has_support_email(self):
return bool(self.support_email)
@property
def has_discord(self):
return bool(self.discord_url)
@property
def has_office_address(self):
return bool(self.office_address_lines)
@property
def has_footer_contact(self):
return self.has_support_email or self.has_office_address
@property
def has_contact_sidebar(self):
return self.has_support_email or self.has_discord or self.has_office_address
@property
def email_card_title_display(self):
return self.email_card_title.strip() or "Email Support"
@property
def discord_card_title_display(self):
return self.discord_card_title.strip() or "Discord Community"
@property
def office_card_title_display(self):
return self.office_card_title.strip() or "Office"
+1
View File
@@ -0,0 +1 @@
+35
View File
@@ -0,0 +1,35 @@
from django.test import RequestFactory, TestCase
from apps.core.context_processors import site_branding
from apps.core.models import SiteBranding
class SiteBrandingModelTests(TestCase):
def test_load_creates_singleton(self):
branding = SiteBranding.load()
self.assertEqual(branding.pk, 1)
self.assertEqual(SiteBranding.objects.count(), 1)
def test_icon_style_includes_border_when_enabled(self):
branding = SiteBranding.load()
branding.show_border = True
branding.border_color = "#ffffff"
branding.border_width = 2
branding.navbar_icon_size = 30
style = branding.navbar_icon_style
self.assertIn("width:30px", style)
self.assertIn("border:2px solid #ffffff", style)
def test_icon_style_omits_border_when_disabled(self):
branding = SiteBranding.load()
branding.show_border = False
self.assertNotIn("border:", branding.navbar_icon_style)
class SiteBrandingContextProcessorTests(TestCase):
def test_site_branding_in_context(self):
SiteBranding.load()
request = RequestFactory().get("/")
ctx = site_branding(request)
self.assertIn("site_branding", ctx)
self.assertIsInstance(ctx["site_branding"], SiteBranding)
+55
View File
@@ -0,0 +1,55 @@
from django.test import Client, RequestFactory, TestCase
from apps.core.context_processors import site_contact
from apps.core.models import SiteContact
class SiteContactModelTests(TestCase):
def test_office_address_lines_skips_blank_lines(self):
contact = SiteContact.load()
contact.office_address = "Line one\n\nLine two"
self.assertEqual(contact.office_address_lines, ["Line one", "Line two"])
def test_has_footer_contact_requires_email_or_office(self):
contact = SiteContact.load()
contact.support_email = ""
contact.office_address = ""
contact.discord_url = "https://discord.gg/example"
self.assertFalse(contact.has_footer_contact)
self.assertTrue(contact.has_discord)
def test_discord_label_default(self):
contact = SiteContact.load()
contact.discord_label = ""
self.assertEqual(contact.discord_label_display, "Join Discord")
class SiteContactContextProcessorTests(TestCase):
def test_site_contact_in_context(self):
SiteContact.load()
request = RequestFactory().get("/")
ctx = site_contact(request)
self.assertIn("site_contact", ctx)
self.assertIsInstance(ctx["site_contact"], SiteContact)
class SiteContactTemplateTests(TestCase):
def test_footer_hides_email_when_empty(self):
contact = SiteContact.load()
contact.support_email = ""
contact.discord_url = ""
contact.office_address = ""
contact.save()
response = Client().get("/")
self.assertNotContains(response, "mailto:")
self.assertNotContains(response, "discord.gg")
def test_contact_page_hides_sidebar_when_empty(self):
contact = SiteContact.load()
contact.support_email = ""
contact.discord_url = ""
contact.office_address = ""
contact.save()
response = Client().get("/contact/")
self.assertNotContains(response, "contact-sidebar")
self.assertContains(response, "contact-layout--full")
+34
View File
@@ -0,0 +1,34 @@
import markdown as _md
from django.utils.html import escape, mark_safe
FORMAT_PLAIN = "plain"
FORMAT_MARKDOWN = "markdown"
FORMAT_HTML = "html"
CONTENT_FORMAT_CHOICES = [
(FORMAT_PLAIN, "Plain Text"),
(FORMAT_MARKDOWN, "Markdown"),
(FORMAT_HTML, "HTML"),
]
_MD_EXTENSIONS = ["extra", "nl2br", "sane_lists"]
def render_content(text: str, fmt: str) -> str:
if not text:
return mark_safe("")
if fmt == FORMAT_HTML:
return mark_safe(text)
if fmt == FORMAT_MARKDOWN:
return mark_safe(_md.markdown(text, extensions=_MD_EXTENSIONS))
paragraphs = text.split("\n\n")
parts = []
for para in paragraphs:
para = para.strip()
if para:
lines = escape(para).split("\n")
parts.append("<p>" + "<br>".join(lines) + "</p>")
return mark_safe("".join(parts) if parts else f"<p>{escape(text)}</p>")
+172
View File
@@ -0,0 +1,172 @@
import re
from django.core.exceptions import ValidationError
from django.db import models
from apps.core.utils import CONTENT_FORMAT_CHOICES, FORMAT_PLAIN, render_content
VIDEO_SOURCE_YOUTUBE = "youtube"
VIDEO_SOURCE_UPLOAD = "upload"
VIDEO_SOURCE_CHOICES = [
(VIDEO_SOURCE_YOUTUBE, "YouTube / external link"),
(VIDEO_SOURCE_UPLOAD, "Uploaded file"),
]
VIDEO_SIZE_SMALL = "sm"
VIDEO_SIZE_MEDIUM = "md"
VIDEO_SIZE_LARGE = "lg"
VIDEO_SIZE_FULL = "full"
VIDEO_SIZE_CHOICES = [
(VIDEO_SIZE_SMALL, "Small (480px)"),
(VIDEO_SIZE_MEDIUM, "Medium (720px)"),
(VIDEO_SIZE_LARGE, "Large (960px)"),
(VIDEO_SIZE_FULL, "Full width"),
]
VIDEO_ASPECT_16_9 = "16/9"
VIDEO_ASPECT_4_3 = "4/3"
VIDEO_ASPECT_1_1 = "1/1"
VIDEO_ASPECT_CHOICES = [
(VIDEO_ASPECT_16_9, "16:9 (widescreen)"),
(VIDEO_ASPECT_4_3, "4:3 (standard)"),
(VIDEO_ASPECT_1_1, "1:1 (square)"),
]
VIDEO_UPLOAD_EXTENSIONS = frozenset({".mp4", ".webm", ".ogg", ".mov"})
VIDEO_ADMIN_FIELDS = (
"video_source",
"video_url",
"video_file",
"video_poster",
"video_size",
"video_aspect_ratio",
"video_styled_background",
)
VIDEO_ADMIN_FIELDSET = (
"Video",
{
"fields": VIDEO_ADMIN_FIELDS + ("video_preview",),
"description": (
"Choose YouTube link or uploaded file. Set display size and aspect ratio "
"to control how the preview appears on the site."
),
},
)
YOUTUBE_ID_PATTERNS = (
re.compile(r"(?:youtube\.com/watch\?(?:[^&]+&)*v=|youtube\.com/embed/|youtube\.com/shorts/|youtu\.be/)([\w-]{11})"),
re.compile(r"^([\w-]{11})$"),
)
def parse_youtube_video_id(url):
if not url:
return ""
value = url.strip()
for pattern in YOUTUBE_ID_PATTERNS:
match = pattern.search(value)
if match:
return match.group(1)
return ""
def youtube_embed_url(url):
video_id = parse_youtube_video_id(url)
if not video_id:
return ""
return f"https://www.youtube-nocookie.com/embed/{video_id}"
class VideoBlockMixin(models.Model):
video_source = models.CharField(
max_length=20,
choices=VIDEO_SOURCE_CHOICES,
default=VIDEO_SOURCE_YOUTUBE,
blank=True,
)
video_url = models.CharField(
max_length=500,
blank=True,
help_text="YouTube watch, embed, or youtu.be link.",
)
video_file = models.FileField(
upload_to="videos/",
blank=True,
help_text="MP4, WebM, OGG, or MOV file.",
)
video_poster = models.ImageField(
upload_to="videos/posters/",
blank=True,
null=True,
help_text="Optional thumbnail shown before an uploaded video plays.",
)
video_size = models.CharField(
max_length=10,
choices=VIDEO_SIZE_CHOICES,
default=VIDEO_SIZE_MEDIUM,
)
video_aspect_ratio = models.CharField(
max_length=10,
choices=VIDEO_ASPECT_CHOICES,
default=VIDEO_ASPECT_16_9,
)
video_styled_background = models.BooleanField(
default=False,
help_text="Glass panel with ambient glow (similar to the Downloads section).",
)
description_format = models.CharField(
max_length=20,
choices=CONTENT_FORMAT_CHOICES,
default=FORMAT_PLAIN,
)
class Meta:
abstract = True
@property
def youtube_embed_url(self):
return youtube_embed_url(self.video_url)
@property
def has_video(self):
if self.video_source == VIDEO_SOURCE_UPLOAD:
return bool(self.video_file)
return bool(self.youtube_embed_url)
@property
def video_size_class(self):
return f"video-block--{self.video_size or VIDEO_SIZE_MEDIUM}"
@property
def video_aspect_class(self):
ratio = (self.video_aspect_ratio or VIDEO_ASPECT_16_9).replace("/", "-")
return f"video-block--ratio-{ratio}"
@property
def rendered_description(self):
description = getattr(self, "description", "") or ""
return render_content(description, self.description_format)
def clean_video_fields(self, require=False):
if not require and not self.video_url and not self.video_file:
return
if self.video_source == VIDEO_SOURCE_YOUTUBE:
if not self.video_url.strip():
raise ValidationError({"video_url": "Enter a YouTube link."})
if not self.youtube_embed_url:
raise ValidationError({"video_url": "Enter a valid YouTube link."})
elif self.video_source == VIDEO_SOURCE_UPLOAD:
if not self.video_file:
raise ValidationError({"video_file": "Upload a video file."})
extension = self.video_file.name.rsplit(".", 1)[-1].lower() if self.video_file.name else ""
if f".{extension}" not in VIDEO_UPLOAD_EXTENSIONS:
raise ValidationError(
{
"video_file": "Unsupported format. Use MP4, WebM, OGG, or MOV.",
}
)
View File
+330
View File
@@ -0,0 +1,330 @@
from django.contrib import admin
from django.http import FileResponse, Http404, HttpResponseRedirect
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 (
AboutSection,
AboutSectionItem,
ContactSubmission,
ContactSubmissionAttachment,
CustomPage,
CustomPageSection,
CustomPageSectionItem,
DownloadItem,
FAQEntry,
HeroSection,
HomepageSection,
HomepageSectionItem,
PageVideo,
)
@admin.register(HeroSection)
class HeroSectionAdmin(admin.ModelAdmin):
fieldsets = (
("Badge", {"fields": ("badge",)}),
("Title", {"fields": ("title", "title_highlight")}),
("Text", {"fields": ("subtitle", "description")}),
("Primary Button", {"fields": ("primary_cta_text", "primary_cta_url")}),
("Secondary Button", {"fields": ("secondary_cta_text", "secondary_cta_url")}),
("Image", {"fields": ("image", "image_alt")}),
)
def has_add_permission(self, request):
return not HeroSection.objects.exists()
def has_delete_permission(self, request, obj=None):
return False
def changelist_view(self, request, extra_context=None):
hero = HeroSection.objects.first()
if hero:
return HttpResponseRedirect(
reverse("admin:pages_herosection_change", args=[hero.pk])
)
return super().changelist_view(request, extra_context)
class HomepageSectionItemInline(admin.TabularInline):
model = HomepageSectionItem
extra = 1
fields = (
"icon",
"title",
"content",
"url",
"image",
"image_alt",
"tags",
"project_status",
"order",
)
ordering = ("order",)
@admin.register(HomepageSection)
class HomepageSectionAdmin(admin.ModelAdmin):
list_display = ("section_type", "title", "badge", "order", "is_active")
list_filter = ("section_type", "is_active")
list_editable = ("order", "is_active")
inlines = [HomepageSectionItemInline]
readonly_fields = ("video_preview",)
fieldsets = (
(None, {"fields": ("section_type", "badge", "title", "title_highlight", "description_format", "description")}),
("CTA Link (About Strip)", {"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)
class AboutSectionItemInline(admin.TabularInline):
model = AboutSectionItem
extra = 1
fields = ("icon", "title", "content", "url", "image", "image_alt", "badge", "is_featured", "order")
ordering = ("order",)
@admin.register(AboutSection)
class AboutSectionAdmin(admin.ModelAdmin):
list_display = ("section_type", "badge", "title", "order", "is_active")
list_filter = ("section_type", "is_active")
list_editable = ("order", "is_active")
inlines = [AboutSectionItemInline]
readonly_fields = ("video_preview",)
fieldsets = (
(None, {"fields": ("section_type", "badge", "title", "subtitle")}),
("Content", {"fields": ("content_format", "content")}),
VIDEO_ADMIN_FIELDSET,
("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)
class ContactSubmissionAdmin(admin.ModelAdmin):
list_display = ("name", "title", "email", "submitted_at", "is_read")
list_filter = ("is_read", "submitted_at")
search_fields = ("name", "title", "description", "email")
list_editable = ("is_read",)
readonly_fields = ("name", "title", "description", "email", "submitted_at")
inlines = [ContactSubmissionAttachmentInline]
fieldsets = (
(None, {"fields": ("name", "title", "email", "description")}),
("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)
class FAQEntryAdmin(admin.ModelAdmin):
list_display = ("question", "order", "is_active", "created_at")
list_filter = ("is_active",)
search_fields = ("question", "answer")
list_editable = ("order", "is_active")
fieldsets = (
(None, {"fields": ("question",)}),
("Answer", {"fields": ("answer_format", "answer")}),
("Settings", {"fields": ("order", "is_active")}),
)
class CustomPageSectionItemInline(admin.TabularInline):
model = CustomPageSectionItem
extra = 1
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)
class DownloadItemAdmin(admin.ModelAdmin):
list_display = ("name", "platform", "version", "is_active", "order")
list_filter = ("platform", "is_active")
search_fields = ("name", "description")
list_editable = ("order", "is_active")
fieldsets = (
(None, {"fields": ("name", "platform", "version", "download_url", "description")}),
("Settings", {"fields": ("order", "is_active")}),
)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class PagesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.pages"
verbose_name = "Pages"
+188
View File
@@ -0,0 +1,188 @@
import os
import uuid
import zipfile
from pathlib import PurePosixPath
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.files.storage import FileSystemStorage
from django.core.files.uploadedfile import UploadedFile
from django.utils.text import get_valid_filename
from PIL import Image
ALLOWED_EXTENSIONS = frozenset(
{
".png",
".jpg",
".jpeg",
".gif",
".webp",
".bmp",
".zip",
".log",
".txt",
}
)
IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"})
TEXT_EXTENSIONS = frozenset({".log", ".txt"})
ARCHIVE_EXTENSIONS = frozenset({".zip"})
MAX_FILE_SIZE = getattr(settings, "CONTACT_ATTACHMENT_MAX_SIZE", 10 * 1024 * 1024)
MAX_ATTACHMENTS = getattr(settings, "CONTACT_ATTACHMENT_MAX_COUNT", 3)
MAX_ZIP_ENTRIES = 100
MAX_ZIP_UNCOMPRESSED_SIZE = 50 * 1024 * 1024
class ContactAttachmentStorage(FileSystemStorage):
def __init__(self):
super().__init__(location=settings.CONTACT_UPLOAD_ROOT)
contact_attachment_storage = ContactAttachmentStorage()
def contact_attachment_upload_to(instance, _filename):
ext = os.path.splitext(instance.original_filename)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
ext = ".bin"
subdir = str(instance.submission_id) if instance.submission_id else "pending"
return f"{subdir}/{uuid.uuid4().hex}{ext}"
def _extension(filename):
return os.path.splitext(filename)[1].lower()
def _read_header(uploaded_file, size=32):
uploaded_file.seek(0)
header = uploaded_file.read(size)
uploaded_file.seek(0)
return header
def _validate_magic(header, ext):
if ext == ".png" and not header.startswith(b"\x89PNG\r\n\x1a\n"):
raise ValidationError("File content does not match its type.")
if ext in (".jpg", ".jpeg") and not header.startswith(b"\xff\xd8\xff"):
raise ValidationError("File content does not match its type.")
if ext == ".gif" and not (
header.startswith(b"GIF87a") or header.startswith(b"GIF89a")
):
raise ValidationError("File content does not match its type.")
if ext == ".webp" and not (
len(header) >= 12 and header[0:4] == b"RIFF" and header[8:12] == b"WEBP"
):
raise ValidationError("File content does not match its type.")
if ext == ".bmp" and not header.startswith(b"BM"):
raise ValidationError("File content does not match its type.")
if ext == ".zip" and not (
header.startswith(b"PK\x03\x04")
or header.startswith(b"PK\x05\x06")
or header.startswith(b"PK\x07\x08")
):
raise ValidationError("File content does not match its type.")
def _validate_image(uploaded_file):
try:
uploaded_file.seek(0)
with Image.open(uploaded_file) as img:
img.verify()
uploaded_file.seek(0)
with Image.open(uploaded_file) as img:
img.load()
except Exception as exc:
raise ValidationError("Invalid or corrupted image file.") from exc
finally:
uploaded_file.seek(0)
def _validate_text(uploaded_file):
uploaded_file.seek(0)
data = uploaded_file.read()
uploaded_file.seek(0)
if b"\x00" in data:
raise ValidationError("Text files must not contain binary data.")
try:
data.decode("utf-8")
except UnicodeDecodeError:
try:
data.decode("latin-1")
except UnicodeDecodeError as exc:
raise ValidationError("Text file is not valid UTF-8 or Latin-1.") from exc
def _validate_zip(uploaded_file):
uploaded_file.seek(0)
if not zipfile.is_zipfile(uploaded_file):
raise ValidationError("Invalid ZIP archive.")
uploaded_file.seek(0)
total_uncompressed = 0
entry_count = 0
with zipfile.ZipFile(uploaded_file, "r") as archive:
for info in archive.infolist():
entry_count += 1
if entry_count > MAX_ZIP_ENTRIES:
raise ValidationError("ZIP archive contains too many files.")
name = info.filename
if name.startswith("/") or ".." in PurePosixPath(name).parts:
raise ValidationError("ZIP archive contains unsafe paths.")
if info.flag_bits & 0x1:
raise ValidationError("Encrypted ZIP archives are not allowed.")
total_uncompressed += info.file_size
if total_uncompressed > MAX_ZIP_UNCOMPRESSED_SIZE:
raise ValidationError("ZIP archive uncompressed size is too large.")
uploaded_file.seek(0)
def validate_contact_attachment(uploaded_file):
if uploaded_file.size > MAX_FILE_SIZE:
max_mb = MAX_FILE_SIZE // (1024 * 1024)
raise ValidationError(f"File exceeds the maximum size of {max_mb} MB.")
name = get_valid_filename(os.path.basename(uploaded_file.name))
if not name:
raise ValidationError("Invalid file name.")
ext = _extension(name)
if ext not in ALLOWED_EXTENSIONS:
raise ValidationError(
"File type not allowed. Permitted: images (PNG, JPG, GIF, WebP, BMP), "
"ZIP, LOG, or TXT."
)
header = _read_header(uploaded_file)
if ext in IMAGE_EXTENSIONS or ext in ARCHIVE_EXTENSIONS:
_validate_magic(header, ext)
if ext in IMAGE_EXTENSIONS:
_validate_image(uploaded_file)
elif ext in TEXT_EXTENSIONS:
_validate_text(uploaded_file)
elif ext in ARCHIVE_EXTENSIONS:
_validate_zip(uploaded_file)
return name
def validate_contact_attachments(files):
if not files:
return []
if len(files) > MAX_ATTACHMENTS:
raise ValidationError(f"You can attach at most {MAX_ATTACHMENTS} files.")
validated = []
for uploaded_file in files:
if not isinstance(uploaded_file, UploadedFile):
raise ValidationError("Invalid upload.")
original_name = validate_contact_attachment(uploaded_file)
validated.append((uploaded_file, original_name))
return validated
+41
View File
@@ -0,0 +1,41 @@
from django import forms
from django.core.exceptions import ValidationError
from .contact_uploads import validate_contact_attachments
from .models import ContactSubmission
class ContactForm(forms.ModelForm):
attachments = forms.Field(required=False)
class Meta:
model = ContactSubmission
fields = ["name", "title", "description", "email"]
widgets = {
"name": forms.TextInput(
attrs={"placeholder": "Your full name", "autocomplete": "name"}
),
"title": forms.TextInput(attrs={"placeholder": "Subject / topic"}),
"description": forms.Textarea(
attrs={"placeholder": "Write your message here…", "rows": 5}
),
"email": forms.EmailInput(
attrs={
"placeholder": "your@email.com (optional)",
"autocomplete": "email",
}
),
}
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
+50
View File
@@ -0,0 +1,50 @@
# Generated by Django 5.2.13 on 2026-04-27 09:07
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='DownloadItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('platform', models.CharField(choices=[('windows', 'Windows'), ('macos', 'macOS'), ('linux', 'Linux')], max_length=20)),
('version', models.CharField(max_length=50)),
('download_url', models.URLField()),
('description', models.TextField(blank=True)),
('is_active', models.BooleanField(default=True)),
('order', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'Download Item',
'verbose_name_plural': 'Download Items',
'ordering': ['order', 'platform'],
},
),
migrations.CreateModel(
name='FAQEntry',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('question', models.CharField(max_length=500)),
('answer', models.TextField()),
('order', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'FAQ Entry',
'verbose_name_plural': 'FAQ Entries',
'ordering': ['order'],
},
),
]
@@ -0,0 +1,30 @@
# Generated by Django 5.0.2 on 2026-05-04 09:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='ContactSubmission',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('title', models.CharField(max_length=300)),
('description', models.TextField()),
('email', models.EmailField(blank=True, max_length=254)),
('submitted_at', models.DateTimeField(auto_now_add=True)),
('is_read', models.BooleanField(default=False)),
],
options={
'verbose_name': 'Contact Submission',
'verbose_name_plural': 'Contact Submissions',
'ordering': ['-submitted_at'],
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2026-05-14 05:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0002_contactsubmission'),
]
operations = [
migrations.AddField(
model_name='faqentry',
name='answer_format',
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
),
]
@@ -0,0 +1,103 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pages", "0003_faqentry_answer_format"),
]
operations = [
migrations.CreateModel(
name="AboutSection",
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"),
],
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,
help_text="Used as subtitle in Hero and year in History.",
max_length=500,
),
),
("content", models.TextField(blank=True)),
(
"content_format",
models.CharField(
choices=[
("plain", "Plain Text"),
("markdown", "Markdown"),
("html", "HTML"),
],
default="markdown",
max_length=20,
),
),
("order", models.PositiveIntegerField(default=0)),
("is_active", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"verbose_name": "About Section",
"verbose_name_plural": "About Sections",
"ordering": ["order"],
},
),
migrations.CreateModel(
name="AboutSectionItem",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
(
"section",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="items",
to="pages.aboutsection",
),
),
("badge", models.CharField(blank=True, max_length=100)),
("title", models.CharField(blank=True, max_length=300)),
(
"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.")),
("image", models.ImageField(blank=True, null=True, upload_to="about/")),
("image_alt", models.CharField(blank=True, max_length=200)),
(
"is_featured",
models.BooleanField(
default=False,
help_text="Mark as featured item (e.g. large screenshot).",
),
),
("order", models.PositiveIntegerField(default=0)),
],
options={
"verbose_name": "About Section Item",
"verbose_name_plural": "About Section Items",
"ordering": ["order"],
},
),
]
@@ -0,0 +1,49 @@
# Generated by Django 5.0.2 on 2026-05-17 14:39
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0004_aboutsection_aboutsectionitem'),
]
operations = [
migrations.CreateModel(
name='HomepageSection',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('section_type', models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products'), ('problems', 'Problems / Value Proposition'), ('about_strip', 'About Strip')], max_length=30, unique=True)),
('badge', models.CharField(blank=True, max_length=100)),
('title', models.CharField(blank=True, max_length=300)),
('description', models.TextField(blank=True)),
('link_text', models.CharField(blank=True, help_text='CTA button label (About Strip).', max_length=100)),
('link_url', models.CharField(blank=True, help_text='CTA button URL (About Strip).', max_length=300)),
('order', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
],
options={
'verbose_name': 'Homepage Section',
'verbose_name_plural': 'Homepage Sections',
'ordering': ['order'],
},
),
migrations.CreateModel(
name='HomepageSectionItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('icon', models.CharField(blank=True, help_text='Emoji or short symbol (e.g. ⚗️).', max_length=20)),
('title', models.CharField(blank=True, max_length=300)),
('content', models.TextField(blank=True)),
('order', models.PositiveIntegerField(default=0)),
('section', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='pages.homepagesection')),
],
options={
'verbose_name': 'Homepage Section Item',
'verbose_name_plural': 'Homepage Section Items',
'ordering': ['order'],
},
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.0.2 on 2026-05-17 14:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0005_homepage_sections'),
]
operations = [
migrations.AddField(
model_name='homepagesectionitem',
name='image',
field=models.ImageField(blank=True, help_text='Logo or image (used for Supporters cards).', null=True, upload_to='homepage/items/'),
),
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, unique=True),
),
]
@@ -0,0 +1,34 @@
# Generated by Django 5.0.2 on 2026-05-20 09:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0006_homepagesectionitem_image_supporters'),
]
operations = [
migrations.CreateModel(
name='HeroSection',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('badge', models.CharField(blank=True, help_text="Small label above the title (e.g. 'Developing since 2021').", max_length=200)),
('title', models.CharField(blank=True, help_text="Main title line (e.g. 'Radiuma,').", max_length=300)),
('title_highlight', models.CharField(blank=True, help_text="Second title line shown in gradient colour (e.g. 'A Powerful Workflow Generator').", max_length=300)),
('subtitle', models.CharField(blank=True, help_text="Short line below the title (e.g. 'for Standardized Radiomics Analysis…').", max_length=500)),
('description', models.TextField(blank=True, help_text='Longer paragraph below the subtitle.')),
('primary_cta_text', models.CharField(blank=True, help_text='Primary button label.', max_length=100)),
('primary_cta_url', models.CharField(blank=True, help_text='Primary button URL (relative or absolute).', max_length=300)),
('secondary_cta_text', models.CharField(blank=True, help_text='Secondary (ghost) button label.', max_length=100)),
('secondary_cta_url', models.CharField(blank=True, help_text='Secondary (ghost) button URL.', max_length=300)),
('image', models.ImageField(blank=True, help_text='App preview screenshot shown on the right.', null=True, upload_to='hero/')),
('image_alt', models.CharField(blank=True, help_text='Alt text for the preview image.', max_length=300)),
],
options={
'verbose_name': 'Hero Section',
'verbose_name_plural': 'Hero Section',
},
),
]
@@ -0,0 +1,79 @@
# Generated by Django 5.0.2 on 2026-05-25 12:55
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0007_hero_section'),
]
operations = [
migrations.CreateModel(
name='CustomPage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('slug', models.SlugField(max_length=200, unique=True)),
('menu_label', models.CharField(blank=True, help_text='Nav label when shown in menu. Defaults to title.', max_length=100)),
('meta_description', models.CharField(blank=True, max_length=300)),
('show_in_nav', models.BooleanField(default=True)),
('menu_order', models.PositiveIntegerField(default=0)),
('is_published', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Custom Page',
'verbose_name_plural': 'Custom Pages',
'ordering': ['menu_order', 'title'],
},
),
migrations.CreateModel(
name='CustomPageSection',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('section_type', models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('features', 'Features Grid'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products Grid'), ('problems', 'Problems / Value Proposition'), ('supporters', 'Supporters'), ('about_strip', 'About Strip'), ('faq', 'FAQ Accordion')], default='custom', max_length=30)),
('badge', models.CharField(blank=True, max_length=100)),
('title', models.CharField(blank=True, max_length=300)),
('subtitle', models.CharField(blank=True, max_length=500)),
('description', models.TextField(blank=True, help_text='Short intro text (homepage-style sections).')),
('content', models.TextField(blank=True)),
('content_format', models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='markdown', max_length=20)),
('link_text', models.CharField(blank=True, max_length=100)),
('link_url', models.CharField(blank=True, max_length=300)),
('order', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='pages.custompage')),
],
options={
'verbose_name': 'Custom Page Section',
'verbose_name_plural': 'Custom Page Sections',
'ordering': ['order'],
},
),
migrations.CreateModel(
name='CustomPageSectionItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('icon', models.CharField(blank=True, max_length=20)),
('badge', models.CharField(blank=True, max_length=100)),
('title', models.CharField(blank=True, max_length=300)),
('content', models.TextField(blank=True)),
('content_format', models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20)),
('url', models.URLField(blank=True)),
('image', models.ImageField(blank=True, null=True, upload_to='pages/custom/')),
('image_alt', models.CharField(blank=True, max_length=200)),
('is_featured', models.BooleanField(default=False)),
('order', models.PositiveIntegerField(default=0)),
('section', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='pages.custompagesection')),
],
options={
'verbose_name': 'Custom Page Section Item',
'verbose_name_plural': 'Custom Page Section Items',
'ordering': ['order'],
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2026-05-26 12:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0008_custom_pages'),
]
operations = [
migrations.AlterField(
model_name='homepagesection',
name='section_type',
field=models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products'), ('problems', 'Problems / Value Proposition'), ('about_strip', 'About Strip'), ('supporters', 'Supporters')], max_length=30),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.0.2 on 2026-05-26 12:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0009_alter_homepagesection_section_type'),
]
operations = [
migrations.AddField(
model_name='homepagesectionitem',
name='url',
field=models.CharField(blank=True, help_text='Optional link; makes this item clickable.', max_length=300),
),
migrations.AlterField(
model_name='custompagesection',
name='section_type',
field=models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('features', 'Features Grid'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products Grid'), ('products_catalog', 'Products with Sub-products'), ('problems', 'Problems / Value Proposition'), ('supporters', 'Supporters'), ('about_strip', 'About Strip'), ('faq', 'FAQ Accordion')], default='custom', max_length=30),
),
migrations.AlterField(
model_name='custompagesectionitem',
name='url',
field=models.CharField(blank=True, help_text='Optional link; makes this item clickable.', max_length=300),
),
migrations.AlterField(
model_name='homepagesection',
name='section_type',
field=models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products'), ('products_catalog', 'Products with Sub-products'), ('problems', 'Problems / Value Proposition'), ('about_strip', 'About Strip'), ('supporters', 'Supporters')], max_length=30),
),
]
@@ -0,0 +1,48 @@
from django.db import migrations, models
import django.db.models.deletion
def set_custom_page_section_item_page(apps, schema_editor):
CustomPageSectionItem = apps.get_model("pages", "CustomPageSectionItem")
for item in CustomPageSectionItem.objects.select_related("section").iterator():
item.page_id = item.section.page_id
item.save(update_fields=["page_id"])
class Migration(migrations.Migration):
dependencies = [
("pages", "0010_homepagesectionitem_url_and_more"),
]
operations = [
migrations.AlterField(
model_name="aboutsectionitem",
name="url",
field=models.CharField(
blank=True,
help_text="Optional link; makes this item clickable.",
max_length=300,
),
),
migrations.AddField(
model_name="custompagesectionitem",
name="page",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="section_items",
to="pages.custompage",
),
),
migrations.RunPython(set_custom_page_section_item_page, migrations.RunPython.noop),
migrations.AlterField(
model_name="custompagesectionitem",
name="page",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="section_items",
to="pages.custompage",
),
),
]
@@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pages", "0011_custompagesectionitem_page_aboutsectionitem_url"),
]
operations = [
migrations.AddField(
model_name="aboutsectionitem",
name="icon",
field=models.CharField(
blank=True,
help_text="Emoji or short symbol (e.g. ⚗️).",
max_length=20,
),
),
]
@@ -0,0 +1,30 @@
# Generated by Django 5.0.2 on 2026-06-07 13:23
import apps.pages.contact_uploads
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0012_aboutsectionitem_icon'),
]
operations = [
migrations.CreateModel(
name='ContactSubmissionAttachment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(storage=apps.pages.contact_uploads.ContactAttachmentStorage(), upload_to=apps.pages.contact_uploads.contact_attachment_upload_to)),
('original_filename', models.CharField(max_length=255)),
('uploaded_at', models.DateTimeField(auto_now_add=True)),
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='pages.contactsubmission')),
],
options={
'verbose_name': 'Contact Attachment',
'verbose_name_plural': 'Contact Attachments',
'ordering': ['uploaded_at'],
},
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.2.13 on 2026-06-07 13:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0013_contact_submission_attachments'),
]
operations = [
migrations.AlterField(
model_name='aboutsection',
name='section_type',
field=models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('supporters', 'Supporters')], default='custom', max_length=30),
),
migrations.AlterField(
model_name='aboutsectionitem',
name='image',
field=models.ImageField(blank=True, help_text='Logo or image (used for Supporters cards).', null=True, upload_to='about/items/'),
),
]
+141
View File
@@ -0,0 +1,141 @@
# Generated by Django 5.2.13 on 2026-06-08 12:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0014_alter_aboutsection_section_type_and_more'),
]
operations = [
migrations.CreateModel(
name='PageVideo',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('video_source', models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20)),
('video_url', models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500)),
('video_file', models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/')),
('video_poster', models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/')),
('video_size', models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10)),
('video_aspect_ratio', models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10)),
('page', models.CharField(choices=[('contact', 'Contact'), ('faq', 'FAQ')], max_length=20)),
('badge', models.CharField(blank=True, max_length=100)),
('title', models.CharField(blank=True, max_length=300)),
('description', models.TextField(blank=True)),
('order', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
],
options={
'verbose_name': 'Page Video',
'verbose_name_plural': 'Page Videos',
'ordering': ['page', 'order'],
},
),
migrations.AddField(
model_name='aboutsection',
name='video_aspect_ratio',
field=models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10),
),
migrations.AddField(
model_name='aboutsection',
name='video_file',
field=models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/'),
),
migrations.AddField(
model_name='aboutsection',
name='video_poster',
field=models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/'),
),
migrations.AddField(
model_name='aboutsection',
name='video_size',
field=models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10),
),
migrations.AddField(
model_name='aboutsection',
name='video_source',
field=models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20),
),
migrations.AddField(
model_name='aboutsection',
name='video_url',
field=models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500),
),
migrations.AddField(
model_name='custompagesection',
name='video_aspect_ratio',
field=models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10),
),
migrations.AddField(
model_name='custompagesection',
name='video_file',
field=models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/'),
),
migrations.AddField(
model_name='custompagesection',
name='video_poster',
field=models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/'),
),
migrations.AddField(
model_name='custompagesection',
name='video_size',
field=models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10),
),
migrations.AddField(
model_name='custompagesection',
name='video_source',
field=models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20),
),
migrations.AddField(
model_name='custompagesection',
name='video_url',
field=models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500),
),
migrations.AddField(
model_name='homepagesection',
name='video_aspect_ratio',
field=models.CharField(choices=[('16/9', '16:9 (widescreen)'), ('4/3', '4:3 (standard)'), ('1/1', '1:1 (square)')], default='16/9', max_length=10),
),
migrations.AddField(
model_name='homepagesection',
name='video_file',
field=models.FileField(blank=True, help_text='MP4, WebM, OGG, or MOV file.', upload_to='videos/'),
),
migrations.AddField(
model_name='homepagesection',
name='video_poster',
field=models.ImageField(blank=True, help_text='Optional thumbnail shown before an uploaded video plays.', null=True, upload_to='videos/posters/'),
),
migrations.AddField(
model_name='homepagesection',
name='video_size',
field=models.CharField(choices=[('sm', 'Small (480px)'), ('md', 'Medium (720px)'), ('lg', 'Large (960px)'), ('full', 'Full width')], default='md', max_length=10),
),
migrations.AddField(
model_name='homepagesection',
name='video_source',
field=models.CharField(blank=True, choices=[('youtube', 'YouTube / external link'), ('upload', 'Uploaded file')], default='youtube', max_length=20),
),
migrations.AddField(
model_name='homepagesection',
name='video_url',
field=models.CharField(blank=True, help_text='YouTube watch, embed, or youtu.be link.', max_length=500),
),
migrations.AlterField(
model_name='aboutsection',
name='section_type',
field=models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('supporters', 'Supporters'), ('video', 'Video')], default='custom', max_length=30),
),
migrations.AlterField(
model_name='custompagesection',
name='section_type',
field=models.CharField(choices=[('hero', 'Hero'), ('intro', 'Intro Card'), ('grid', 'Grid Cards'), ('history', 'History Block'), ('custom', 'Custom Content'), ('features', 'Features Grid'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products Grid'), ('products_catalog', 'Products with Sub-products'), ('problems', 'Problems / Value Proposition'), ('supporters', 'Supporters'), ('about_strip', 'About Strip'), ('faq', 'FAQ Accordion'), ('video', 'Video')], default='custom', max_length=30),
),
migrations.AlterField(
model_name='homepagesection',
name='section_type',
field=models.CharField(choices=[('features', 'Features'), ('screenshots', 'Screenshots Gallery'), ('products', 'Products'), ('products_catalog', 'Products with Sub-products'), ('problems', 'Problems / Value Proposition'), ('about_strip', 'About Strip'), ('supporters', 'Supporters'), ('video', 'Video')], max_length=30),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.2.13 on 2026-06-08 13:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0015_video_support'),
]
operations = [
migrations.AddField(
model_name='aboutsection',
name='video_styled_background',
field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'),
),
migrations.AddField(
model_name='custompagesection',
name='video_styled_background',
field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'),
),
migrations.AddField(
model_name='homepagesection',
name='video_styled_background',
field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'),
),
migrations.AddField(
model_name='pagevideo',
name='video_styled_background',
field=models.BooleanField(default=False, help_text='Glass panel with ambient glow (similar to the Downloads section).'),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.2.13 on 2026-06-08 13:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0016_video_styled_background'),
]
operations = [
migrations.AddField(
model_name='aboutsection',
name='description_format',
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
),
migrations.AddField(
model_name='custompagesection',
name='description_format',
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
),
migrations.AddField(
model_name='homepagesection',
name='description_format',
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
),
migrations.AddField(
model_name='pagevideo',
name='description_format',
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
),
]
@@ -0,0 +1,48 @@
# Generated by Django 5.2.15 on 2026-06-20 23:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pages', '0017_video_description_format'),
]
operations = [
migrations.AddField(
model_name='homepagesection',
name='title_highlight',
field=models.CharField(blank=True, help_text="Second title line shown in accent colour (e.g. 'Outstanding Projects').", max_length=300),
),
migrations.AddField(
model_name='homepagesectionitem',
name='image_alt',
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='homepagesectionitem',
name='project_status',
field=models.CharField(blank=True, choices=[('', ''), ('new', 'New'), ('ongoing', 'Ongoing'), ('done', 'Done')], help_text='Project filter category (Projects Showcase section).', max_length=10),
),
migrations.AddField(
model_name='homepagesectionitem',
name='tags',
field=models.CharField(blank=True, help_text='Comma-separated tags shown on project cards (e.g. Web Development, Publication).', max_length=300),
),
migrations.AlterField(
model_name='herosection',
name='title',
field=models.CharField(blank=True, help_text="Main title line (e.g. 'Advanced Solutions').", 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'), ('projects', 'Projects Showcase'), ('experience', 'Experience / Benefits'), ('about_strip', 'About Strip'), ('supporters', 'Supporters'), ('video', 'Video')], max_length=30),
),
migrations.AlterField(
model_name='homepagesectionitem',
name='image',
field=models.ImageField(blank=True, help_text='Card image or icon.', null=True, upload_to='homepage/items/'),
),
]
View File
+479
View File
@@ -0,0 +1,479 @@
from django.core.exceptions import ValidationError
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.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):
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. 'Advanced Solutions').")
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…').")
description = models.TextField(blank=True, help_text="Longer paragraph below the subtitle.")
primary_cta_text = models.CharField(max_length=100, blank=True, help_text="Primary button label.")
primary_cta_url = models.CharField(max_length=300, blank=True, help_text="Primary button URL (relative or absolute).")
secondary_cta_text = models.CharField(max_length=100, blank=True, help_text="Secondary (ghost) button label.")
secondary_cta_url = models.CharField(max_length=300, blank=True, help_text="Secondary (ghost) button URL.")
image = models.ImageField(upload_to="hero/", blank=True, null=True, help_text="App preview screenshot shown on the right.")
image_alt = models.CharField(max_length=300, blank=True, help_text="Alt text for the preview image.")
class Meta:
verbose_name = "Hero Section"
verbose_name_plural = "Hero Section"
def __str__(self):
return "Hero Section"
class HomepageSection(VideoBlockMixin, models.Model):
TYPE_FEATURES = "features"
TYPE_SCREENSHOTS = "screenshots"
TYPE_PRODUCTS = "products"
TYPE_PRODUCTS_CATALOG = "products_catalog"
TYPE_PROBLEMS = "problems"
TYPE_PROJECTS = "projects"
TYPE_EXPERIENCE = "experience"
TYPE_ABOUT_STRIP = "about_strip"
TYPE_SUPPORTERS = "supporters"
TYPE_VIDEO = "video"
TYPE_CHOICES = [
(TYPE_FEATURES, "Features"),
(TYPE_SCREENSHOTS, "Screenshots Gallery"),
(TYPE_PRODUCTS, "Products"),
(TYPE_PRODUCTS_CATALOG, "Products with Sub-products"),
(TYPE_PROBLEMS, "Problems / Value Proposition"),
(TYPE_PROJECTS, "Projects Showcase"),
(TYPE_EXPERIENCE, "Experience / Benefits"),
(TYPE_ABOUT_STRIP, "About Strip"),
(TYPE_SUPPORTERS, "Supporters"),
(TYPE_VIDEO, "Video"),
]
section_type = models.CharField(max_length=30, choices=TYPE_CHOICES)
badge = models.CharField(max_length=100, blank=True)
title = models.CharField(max_length=300, blank=True)
title_highlight = models.CharField(
max_length=300,
blank=True,
help_text="Second title line shown in accent colour (e.g. 'Outstanding Projects').",
)
description = models.TextField(blank=True)
link_text = models.CharField(max_length=100, blank=True, help_text="CTA button label (About Strip).")
link_url = models.CharField(max_length=300, blank=True, help_text="CTA button URL (About Strip).")
order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ["order"]
verbose_name = "Homepage Section"
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):
return f"[{self.get_section_type_display()}] {self.title or self.badge}"
class HomepageSectionItem(models.Model):
STATUS_NEW = "new"
STATUS_ONGOING = "ongoing"
STATUS_DONE = "done"
PROJECT_STATUS_CHOICES = [
("", ""),
(STATUS_NEW, "New"),
(STATUS_ONGOING, "Ongoing"),
(STATUS_DONE, "Done"),
]
section = models.ForeignKey(HomepageSection, on_delete=models.CASCADE, related_name="items")
icon = models.CharField(max_length=20, blank=True, help_text="Emoji or short symbol (e.g. ⚗️).")
title = models.CharField(max_length=300, 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="Card image or icon.")
image_alt = models.CharField(max_length=200, blank=True)
tags = models.CharField(
max_length=300,
blank=True,
help_text="Comma-separated tags shown on project cards (e.g. Web Development, Publication).",
)
project_status = models.CharField(
max_length=10,
choices=PROJECT_STATUS_CHOICES,
blank=True,
help_text="Project filter category (Projects Showcase section).",
)
order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["order"]
verbose_name = "Homepage Section Item"
verbose_name_plural = "Homepage Section Items"
def __str__(self):
return f"{self.section} {self.title or self.icon or '(item)'}"
@property
def tag_list(self):
if not self.tags.strip():
return []
return [tag.strip() for tag in self.tags.split(",") if tag.strip()]
class AboutSection(VideoBlockMixin, models.Model):
TYPE_HERO = "hero"
TYPE_INTRO = "intro"
TYPE_GRID = "grid"
TYPE_HISTORY = "history"
TYPE_CUSTOM = "custom"
TYPE_SUPPORTERS = "supporters"
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_SUPPORTERS, "Supporters"),
(TYPE_VIDEO, "Video"),
]
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, help_text="Used as subtitle in Hero and year in History.")
content = models.TextField(blank=True)
content_format = models.CharField(max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_MARKDOWN)
order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["order"]
verbose_name = "About Section"
verbose_name_plural = "About 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.get_section_type_display()}] {label}"
class AboutSectionItem(models.Model):
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)
title = models.CharField(max_length=300, blank=True)
content = models.TextField(blank=True, help_text="Description text or link label for History links.")
url = models.CharField(max_length=300, blank=True, help_text="Optional link; makes this item clickable.")
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)
is_featured = models.BooleanField(default=False, help_text="Mark as featured item (e.g. large screenshot).")
order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["order"]
verbose_name = "About Section Item"
verbose_name_plural = "About Section Items"
def __str__(self):
return f"{self.section} {self.title or self.icon or self.badge or '(item)'}"
class ContactSubmission(models.Model):
name = models.CharField(max_length=200)
title = models.CharField(max_length=300)
description = models.TextField()
email = models.EmailField(blank=True)
submitted_at = models.DateTimeField(auto_now_add=True)
is_read = models.BooleanField(default=False)
class Meta:
ordering = ["-submitted_at"]
verbose_name = "Contact Submission"
verbose_name_plural = "Contact Submissions"
def __str__(self):
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):
question = models.CharField(max_length=500)
answer = models.TextField()
answer_format = models.CharField(
max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN
)
order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["order"]
verbose_name = "FAQ Entry"
verbose_name_plural = "FAQ Entries"
def __str__(self):
return self.question
@property
def rendered_answer(self):
return render_content(self.answer, self.answer_format)
class DownloadItem(models.Model):
PLATFORM_WINDOWS = "windows"
PLATFORM_MACOS = "macos"
PLATFORM_LINUX = "linux"
PLATFORM_CHOICES = [
(PLATFORM_WINDOWS, "Windows"),
(PLATFORM_MACOS, "macOS"),
(PLATFORM_LINUX, "Linux"),
]
name = models.CharField(max_length=200)
platform = models.CharField(max_length=20, choices=PLATFORM_CHOICES)
version = models.CharField(max_length=50)
download_url = models.URLField()
description = models.TextField(blank=True)
is_active = models.BooleanField(default=True)
order = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["order", "platform"]
verbose_name = "Download Item"
verbose_name_plural = "Download Items"
def __str__(self):
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)'}"
View File
+158
View File
@@ -0,0 +1,158 @@
import io
import zipfile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase, override_settings
from django.urls import reverse
from PIL import Image
from apps.pages.contact_uploads import (
MAX_ATTACHMENTS,
MAX_FILE_SIZE,
validate_contact_attachments,
)
from apps.pages.models import ContactSubmission, ContactSubmissionAttachment
def _png_file(name="screenshot.png"):
buffer = io.BytesIO()
Image.new("RGB", (8, 8), color="red").save(buffer, format="PNG")
buffer.seek(0)
return SimpleUploadedFile(name, buffer.read(), content_type="image/png")
def _zip_file(name="logs.zip", entries=None):
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
for entry_name, content in (entries or {"app.log": "line one\n"}).items():
archive.writestr(entry_name, content)
buffer.seek(0)
return SimpleUploadedFile(name, buffer.read(), content_type="application/zip")
class ContactUploadValidationTest(TestCase):
def test_accepts_valid_png(self):
validated = validate_contact_attachments([_png_file()])
self.assertEqual(len(validated), 1)
self.assertEqual(validated[0][1], "screenshot.png")
def test_accepts_valid_zip(self):
validated = validate_contact_attachments([_zip_file()])
self.assertEqual(len(validated), 1)
def test_accepts_valid_text_log(self):
uploaded = SimpleUploadedFile(
"error.log",
b"2026-06-07 ERROR something failed\n",
content_type="text/plain",
)
validated = validate_contact_attachments([uploaded])
self.assertEqual(validated[0][1], "error.log")
def test_rejects_executable_extension(self):
uploaded = SimpleUploadedFile(
"malware.exe",
b"MZfake",
content_type="application/octet-stream",
)
with self.assertRaises(Exception):
validate_contact_attachments([uploaded])
def test_rejects_php_disguised_as_png(self):
uploaded = SimpleUploadedFile(
"image.png",
b"<?php echo 'bad'; ?>",
content_type="image/png",
)
with self.assertRaises(Exception):
validate_contact_attachments([uploaded])
def test_rejects_oversized_file(self):
uploaded = SimpleUploadedFile(
"big.log",
b"x" * (MAX_FILE_SIZE + 1),
content_type="text/plain",
)
with self.assertRaises(Exception):
validate_contact_attachments([uploaded])
def test_rejects_too_many_files(self):
files = [_png_file(f"shot-{index}.png") for index in range(MAX_ATTACHMENTS + 1)]
with self.assertRaises(Exception):
validate_contact_attachments(files)
def test_rejects_zip_with_path_traversal(self):
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
archive.writestr("../escape.txt", "bad")
buffer.seek(0)
uploaded = SimpleUploadedFile(
"bad.zip",
buffer.read(),
content_type="application/zip",
)
with self.assertRaises(Exception):
validate_contact_attachments([uploaded])
@override_settings(
CONTACT_UPLOAD_ROOT=__import__("pathlib").Path(__file__).resolve().parents[3]
/ "test_private_uploads"
)
class ContactViewUploadTest(TestCase):
def setUp(self):
self.client = Client(enforce_csrf_checks=True)
self.url = reverse("pages:contact")
def _start_session(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.csrf_token = response.cookies["csrftoken"].value
captcha_question = response.context["captcha_question"]
left, right = captcha_question.split(" + ")
return int(left) + int(right)
def _post_contact(self, captcha_answer, attachments=None, extra=None):
payload = {
"name": "Test User",
"title": "Upload test",
"description": "Testing attachments",
"email": "test@example.com",
"captcha_answer": captcha_answer,
}
if attachments is not None:
payload["attachments"] = attachments
if extra:
payload.update(extra)
return self.client.post(
self.url,
data=payload,
HTTP_X_CSRFTOKEN=self.csrf_token,
)
def test_contact_submission_with_png_attachment(self):
captcha_answer = self._start_session()
response = self._post_contact(captcha_answer, attachments=_png_file())
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["success"])
submission = ContactSubmission.objects.get(title="Upload test")
self.assertEqual(submission.attachments.count(), 1)
attachment = submission.attachments.first()
self.assertEqual(attachment.original_filename, "screenshot.png")
self.assertTrue(attachment.file.storage.exists(attachment.file.name))
def test_contact_submission_rejects_invalid_attachment(self):
captcha_answer = self._start_session()
response = self._post_contact(
captcha_answer,
attachments=SimpleUploadedFile(
"bad.exe",
b"MZ",
content_type="application/octet-stream",
),
)
self.assertEqual(response.status_code, 400)
self.assertFalse(response.json()["success"])
self.assertIn("attachments", response.json()["errors"])
self.assertEqual(ContactSubmission.objects.count(), 0)
self.assertEqual(ContactSubmissionAttachment.objects.count(), 0)
+94
View File
@@ -0,0 +1,94 @@
from django.test import TestCase
from django.urls import reverse
from apps.pages.models import CustomPage, CustomPageSection, CustomPageSectionItem
class CustomPageViewTest(TestCase):
def setUp(self):
self.page = CustomPage.objects.create(
title="Resources",
slug="resources",
show_in_nav=True,
menu_order=5,
is_published=True,
)
CustomPageSection.objects.create(
page=self.page,
section_type=CustomPageSection.TYPE_HERO,
title="Resources",
badge="Docs",
is_active=True,
)
CustomPage.objects.create(
title="Draft Page",
slug="draft",
is_published=False,
)
def test_published_page_returns_200(self):
response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "resources"}))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "pages/custom_page.html")
self.assertEqual(response.context["custom_page"], self.page)
def test_unpublished_page_returns_404(self):
response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "draft"}))
self.assertEqual(response.status_code, 404)
def test_unknown_slug_returns_404(self):
response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "missing"}))
self.assertEqual(response.status_code, 404)
def test_section_item_with_url_renders_as_link(self):
section = CustomPageSection.objects.create(
page=self.page,
section_type=CustomPageSection.TYPE_FEATURES,
title="Highlights",
is_active=True,
)
CustomPageSectionItem.objects.create(
page=self.page,
section=section,
title="Documentation",
content="Read the docs.",
url="/about/",
order=1,
)
response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "resources"}))
self.assertContains(response, 'href="/about/"')
self.assertContains(response, "section-item-link")
self.assertContains(response, "Documentation")
class CustomPageNavTest(TestCase):
def test_nav_custom_pages_in_context(self):
CustomPage.objects.create(
title="Visible",
slug="visible",
show_in_nav=True,
menu_order=1,
is_published=True,
)
CustomPage.objects.create(
title="Hidden Nav",
slug="hidden-nav",
show_in_nav=False,
is_published=True,
)
response = self.client.get(reverse("pages:home"))
pages = list(response.context["nav_custom_pages"])
self.assertEqual(len(pages), 1)
self.assertEqual(pages[0].slug, "visible")
def test_nav_link_rendered(self):
CustomPage.objects.create(
title="Team",
slug="team",
menu_label="Our Team",
show_in_nav=True,
is_published=True,
)
response = self.client.get(reverse("pages:home"))
self.assertContains(response, "Our Team")
self.assertContains(response, reverse("pages:custom_page", kwargs={"slug": "team"}))
+187
View File
@@ -0,0 +1,187 @@
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 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")
class ProductVideoTest(TestCase):
def setUp(self):
self.main_product = MainProduct.objects.create(
name="Tecvico",
slug="tecvico",
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": "tecvico"})
)
self.assertContains(response, "Product Demo")
self.assertContains(response, "video-block--full")
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>")
+125
View File
@@ -0,0 +1,125 @@
from django.test import TestCase
from django.urls import reverse
from apps.pages.models import FAQEntry
class HomeViewTest(TestCase):
def test_home_returns_200(self):
response = self.client.get(reverse("pages:home"))
self.assertEqual(response.status_code, 200)
def test_home_uses_correct_template(self):
response = self.client.get(reverse("pages:home"))
self.assertTemplateUsed(response, "pages/home.html")
def test_home_renders_dynamic_showcase_sections(self):
from apps.pages.models import HomepageSection
projects = HomepageSection.objects.create(
section_type=HomepageSection.TYPE_PROJECTS,
title="Our Journey in the Realm of",
title_highlight="Outstanding Projects",
description="Explore our standout projects.",
order=50,
is_active=True,
)
HomepageSection.objects.create(
section_type=HomepageSection.TYPE_EXPERIENCE,
title="Experience Leading",
title_highlight="the Way in Development",
description="Embark on a journey of accelerated product development.",
order=51,
is_active=True,
)
response = self.client.get(reverse("pages:home"))
content = response.content.decode()
self.assertIn("Our Journey in the Realm of", content)
self.assertIn("Outstanding Projects", content)
self.assertIn("Experience Leading", content)
self.assertIn("the Way in Development", content)
self.assertIn('data-project-filter="all"', content)
class AboutViewTest(TestCase):
def test_about_returns_200(self):
response = self.client.get(reverse("pages:about"))
self.assertEqual(response.status_code, 200)
def test_about_uses_correct_template(self):
response = self.client.get(reverse("pages:about"))
self.assertTemplateUsed(response, "pages/about.html")
class FAQViewTest(TestCase):
def setUp(self):
FAQEntry.objects.create(
question="What is the license?",
answer="It is CC BY-NC-SA.",
order=1,
is_active=True,
)
FAQEntry.objects.create(
question="Hidden question",
answer="Hidden answer",
order=2,
is_active=False,
)
def test_faq_returns_200(self):
response = self.client.get(reverse("pages:faq"))
self.assertEqual(response.status_code, 200)
def test_faq_uses_correct_template(self):
response = self.client.get(reverse("pages:faq"))
self.assertTemplateUsed(response, "pages/faq.html")
def test_faq_only_shows_active_entries(self):
response = self.client.get(reverse("pages:faq"))
entries = response.context["faq_entries"]
self.assertEqual(entries.count(), 1)
self.assertEqual(entries.first().question, "What is the license?")
class ContactViewTest(TestCase):
def test_contact_returns_200(self):
response = self.client.get(reverse("pages:contact"))
self.assertEqual(response.status_code, 200)
def test_contact_uses_correct_template(self):
response = self.client.get(reverse("pages:contact"))
self.assertTemplateUsed(response, "pages/contact.html")
class NavigationContextTest(TestCase):
def test_nav_main_products_in_context_on_all_pages(self):
urls = [
reverse("pages:home"),
reverse("pages:about"),
reverse("pages:faq"),
reverse("pages:contact"),
reverse("products:overview"),
]
for url in urls:
response = self.client.get(url)
self.assertIn(
"nav_main_products",
response.context,
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}",
)
+13
View File
@@ -0,0 +1,13 @@
from django.urls import path
from . import views
app_name = "pages"
urlpatterns = [
path("", views.HomeView.as_view(), name="home"),
path("about/", views.AboutView.as_view(), name="about"),
path("faq/", views.FAQView.as_view(), name="faq"),
path("contact/", views.ContactView.as_view(), name="contact"),
path("<slug>/", views.CustomPageView.as_view(), name="custom_page"),
]
+165
View File
@@ -0,0 +1,165 @@
import random
from django.db.models import Prefetch
from django.http import JsonResponse
from django.shortcuts import render
from django.views import View
from django.views.generic import DetailView, ListView, TemplateView
from apps.products.models import MainProduct, SubProduct
from .forms import ContactForm
from .models import (
AboutSection,
ContactSubmission,
ContactSubmissionAttachment,
CustomPage,
FAQEntry,
HeroSection,
HomepageSection,
PageVideo,
)
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):
template_name = "pages/home.html"
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["hero"] = HeroSection.objects.first()
ctx["homepage_sections"] = (
HomepageSection.objects.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 AboutView(TemplateView):
template_name = "pages/about.html"
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["about_sections"] = (
AboutSection.objects.filter(is_active=True)
.prefetch_related("items")
.order_by("order")
)
return ctx
class FAQView(ListView):
model = FAQEntry
template_name = "pages/faq.html"
context_object_name = "faq_entries"
queryset = FAQEntry.objects.filter(is_active=True)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["faq_videos"] = PageVideo.objects.filter(
page=PageVideo.PAGE_FAQ,
is_active=True,
).order_by("order")
return ctx
class ContactView(View):
template_name = "pages/contact.html"
def _new_captcha(self, request):
a, b = random.randint(1, 9), random.randint(1, 9)
request.session["captcha_answer"] = a + b
return f"{a} + {b}"
def get(self, request, *args, **kwargs):
return render(
request,
self.template_name,
{
"form": ContactForm(),
"captcha_question": self._new_captcha(request),
"contact_videos": PageVideo.objects.filter(
page=PageVideo.PAGE_CONTACT,
is_active=True,
).order_by("order"),
},
)
def post(self, request, *args, **kwargs):
form = ContactForm(
request.POST,
file_list=request.FILES.getlist("attachments"),
)
expected = request.session.get("captcha_answer")
captcha_question = self._new_captcha(request)
captcha_ok = False
try:
captcha_ok = int(request.POST.get("captcha_answer", "")) == expected
except (ValueError, TypeError):
pass
if form.is_valid() and captcha_ok:
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})
errors: dict = {}
if not captcha_ok:
errors["captcha"] = ["Incorrect answer — please try again."]
errors.update(
{field: [str(e) for e in errs] for field, errs in form.errors.items()}
)
return JsonResponse(
{"success": False, "errors": errors, "captcha_question": captcha_question},
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
View File
+334
View File
@@ -0,0 +1,334 @@
from django.contrib import admin
from apps.core.admin_video import video_admin_preview
from apps.core.video import VIDEO_ADMIN_FIELDSET
from .models import Article, ArticleCitation, ArticleSection, MainProduct, ProductVideo, SubProduct, SubProductVersion
class ArticleCitationInline(admin.TabularInline):
model = ArticleCitation
extra = 1
fields = ("text", "url", "order")
ordering = ("order",)
class ArticleSectionInline(admin.TabularInline):
model = ArticleSection
extra = 1
fields = ("title", "value_format", "value", "order")
ordering = ("order",)
class SubProductArticleInline(admin.StackedInline):
model = Article
fk_name = "sub_product"
extra = 0
fields = ("badge", "title", "description", "order")
ordering = ("order",)
show_change_link = True
class MainProductArticleInline(admin.StackedInline):
model = Article
fk_name = "main_product"
extra = 0
fields = ("badge", "title", "description", "order")
ordering = ("order",)
show_change_link = True
RELEASE_VERSION_INLINE_FIELDS = (
"version",
"release_channel",
"channel_label",
"is_featured",
"show_on_product_page",
"windows_download_url",
"macos_download_url",
"linux_download_url",
"source_code_url",
"package_resource_url",
"release_notes",
"is_active",
"order",
)
class SubProductVersionInline(admin.TabularInline):
model = SubProductVersion
fk_name = "sub_product"
extra = 0
ordering = ("order", "version")
fields = RELEASE_VERSION_INLINE_FIELDS
show_change_link = True
class MainProductVersionInline(admin.TabularInline):
model = SubProductVersion
fk_name = "main_product"
extra = 0
ordering = ("order", "version")
fields = RELEASE_VERSION_INLINE_FIELDS
show_change_link = True
class ProductVideoInline(admin.StackedInline):
model = ProductVideo
fk_name = "main_product"
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):
model = SubProduct
extra = 0
fields = ("name", "slug", "distribution", "short_description", "image", "logo", "show_on_homepage", "homepage_order", "order", "is_active")
ordering = ("order",)
show_change_link = True
prepopulated_fields = {"slug": ("name",)}
@admin.register(MainProduct)
class MainProductAdmin(admin.ModelAdmin):
list_display = ("name", "show_on_homepage", "homepage_order", "order", "is_active", "created_at")
list_filter = ("is_active", "show_on_homepage")
search_fields = ("name", "description")
prepopulated_fields = {"slug": ("name",)}
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
inlines = [MainProductArticleInline, ProductVideoInline, MainProductVersionInline, SubProductInline]
fieldsets = (
(
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")}),
("Media", {"fields": ("image",)}),
("Homepage", {"fields": ("show_on_homepage", "homepage_order")}),
("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)
class SubProductAdmin(admin.ModelAdmin):
list_display = (
"name",
"main_product",
"distribution",
"show_on_homepage",
"homepage_order",
"order",
"is_active",
"created_at",
)
list_filter = ("is_active", "distribution", "main_product", "show_on_homepage")
search_fields = ("name", "description", "main_product__name")
prepopulated_fields = {"slug": ("name",)}
list_editable = ("order", "is_active", "show_on_homepage", "homepage_order")
raw_id_fields = ("main_product",)
inlines = [SubProductArticleInline, SubProductVideoInline, SubProductVersionInline]
fieldsets = (
(
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")}),
("Media", {"fields": ("image", "logo")}),
("Homepage", {"fields": ("show_on_homepage", "homepage_order")}),
("Settings", {"fields": ("order", "is_active")}),
)
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ("title", "parent", "order", "created_at")
list_filter = ("main_product", "sub_product__main_product")
search_fields = ("title", "description", "main_product__name", "sub_product__name")
list_editable = ("order",)
raw_id_fields = ("main_product", "sub_product")
inlines = [ArticleSectionInline, ArticleCitationInline]
fieldsets = (
(None, {"fields": ("main_product", "sub_product", "badge", "title")}),
("Description", {"fields": ("description_format", "description")}),
("Citation Badge", {"fields": ("citation_count_display", "citation_count_label"), "description": "Number and optional label for the dashed citation circle badge. Both are optional — a label without a number shows an empty circle with the label; neither hides the badge entirely."}),
("Settings", {"fields": ("order",)}),
)
@admin.display(description="Parent")
def parent(self, obj):
if obj.main_product_id:
return obj.main_product
if obj.sub_product_id:
return obj.sub_product
return ""
@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)
class ArticleSectionAdmin(admin.ModelAdmin):
list_display = ("title", "article", "value_format", "order")
search_fields = ("title", "value", "article__title")
list_editable = ("order",)
raw_id_fields = ("article",)
fieldsets = (
(None, {"fields": ("article", "title")}),
("Content", {"fields": ("value_format", "value")}),
("Settings", {"fields": ("order",)}),
)
@admin.register(SubProductVersion)
class SubProductVersionAdmin(admin.ModelAdmin):
list_display = (
"parent",
"version",
"release_channel",
"channel_label",
"is_featured",
"show_on_product_page",
"is_active",
"order",
)
list_filter = ("is_active", "release_channel", "is_featured", "show_on_product_page", "main_product", "sub_product__main_product")
search_fields = ("main_product__name", "sub_product__name", "version")
list_editable = ("is_active", "order")
raw_id_fields = ("main_product", "sub_product")
fieldsets = (
(
None,
{
"description": (
"Set a preset channel badge (Stable, Beta, Previous, etc.) or write a custom "
"label. Mark one release as Primary (featured) for the main download block. "
"Enable “Show on product page” for additional inline channels such as beta or "
"previous versions."
),
"fields": (
"main_product",
"sub_product",
"version",
"release_channel",
"channel_label",
"is_featured",
"show_on_product_page",
)
},
),
(
"Installable downloads",
{
"description": (
"For each platform, set an external URL, upload a file, both, or neither. "
"When a URL is set it is used on the site; otherwise an uploaded file is served. "
"To delete an uploaded file from the server, open this release, check Clear next to the file, and Save."
),
"fields": (
"windows_download_url",
"windows_download_file",
"macos_download_url",
"macos_download_file",
"linux_download_url",
"linux_download_file",
"source_code_url",
"source_code_file",
),
},
),
("Package link", {"fields": ("package_resource_url",)}),
("Details", {"fields": ("release_notes",)}),
("Settings", {"fields": ("is_active", "order")}),
)
@admin.display(description="Parent")
def parent(self, obj):
if obj.main_product_id:
return obj.main_product
if obj.sub_product_id:
return obj.sub_product
return ""
+10
View File
@@ -0,0 +1,10 @@
from django.apps import AppConfig
class ProductsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.products"
verbose_name = "Products"
def ready(self):
from . import signals # noqa: F401
+93
View File
@@ -0,0 +1,93 @@
# Generated by Django 5.2.13 on 2026-04-27 09:07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Article',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=300)),
('description', models.TextField()),
('order', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Article',
'verbose_name_plural': 'Articles',
'ordering': ['order', 'title'],
},
),
migrations.CreateModel(
name='MainProduct',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('slug', models.SlugField(blank=True, unique=True)),
('short_description', models.CharField(max_length=300)),
('description', models.TextField()),
('image', models.ImageField(blank=True, null=True, upload_to='products/main/')),
('order', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Main Product',
'verbose_name_plural': 'Main Products',
'ordering': ['order', 'name'],
},
),
migrations.CreateModel(
name='ArticleSection',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('value', models.TextField()),
('order', models.PositiveIntegerField(default=0)),
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='products.article')),
],
options={
'verbose_name': 'Article Section',
'verbose_name_plural': 'Article Sections',
'ordering': ['order'],
},
),
migrations.CreateModel(
name='SubProduct',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('slug', models.SlugField(blank=True)),
('short_description', models.CharField(max_length=300)),
('description', models.TextField()),
('image', models.ImageField(blank=True, null=True, upload_to='products/sub/')),
('order', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('main_product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sub_products', to='products.mainproduct')),
],
options={
'verbose_name': 'Sub Product',
'verbose_name_plural': 'Sub Products',
'ordering': ['order', 'name'],
'unique_together': {('main_product', 'slug')},
},
),
migrations.AddField(
model_name='article',
name='sub_product',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='articles', to='products.subproduct'),
),
]
@@ -0,0 +1,34 @@
# Generated by Django 5.0.2 on 2026-05-14 04:53
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='SubProductRelease',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('platform', models.CharField(choices=[('windows', 'Windows'), ('macos', 'macOS'), ('linux', 'Linux')], max_length=20)),
('release_type', models.CharField(choices=[('stable', 'Stable'), ('previous', 'Previous')], default='stable', max_length=20)),
('version', models.CharField(help_text='e.g. 2.1.0', max_length=50)),
('download_url', models.URLField()),
('release_notes', models.TextField(blank=True)),
('is_active', models.BooleanField(default=True)),
('order', models.PositiveIntegerField(default=0)),
('sub_product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='releases', to='products.subproduct')),
],
options={
'verbose_name': 'Release',
'verbose_name_plural': 'Releases',
'ordering': ['release_type', 'platform', 'order'],
'unique_together': {('sub_product', 'platform', 'release_type')},
},
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.0.2 on 2026-05-14 05:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0002_subproductrelease'),
]
operations = [
migrations.AddField(
model_name='article',
name='description_format',
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
),
migrations.AddField(
model_name='articlesection',
name='value_format',
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
),
migrations.AddField(
model_name='mainproduct',
name='description_format',
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
),
migrations.AddField(
model_name='subproduct',
name='description_format',
field=models.CharField(choices=[('plain', 'Plain Text'), ('markdown', 'Markdown'), ('html', 'HTML')], default='plain', max_length=20),
),
]
@@ -0,0 +1,145 @@
from django.db import migrations, models
import django.db.models.deletion
def migrate_legacy_releases_to_versions(apps, schema_editor):
OldRelease = apps.get_model("products", "SubProductRelease")
Version = apps.get_model("products", "SubProductVersion")
PLATFORM_MAP = {
"windows": "windows_download_url",
"macos": "macos_download_url",
"linux": "linux_download_url",
}
sub_ids = (
OldRelease.objects.values_list("sub_product_id", flat=True).distinct().order_by()
)
for sub_id in sub_ids:
used_labels = set()
for release_type in ("stable", "previous"):
slab = OldRelease.objects.filter(
sub_product_id=sub_id, release_type=release_type
).order_by("order", "pk")
if not slab.exists():
continue
urls = {}
labels = []
orders = []
notes = []
any_active = False
for r in slab:
orders.append(r.order)
if r.is_active:
any_active = True
f = PLATFORM_MAP.get(r.platform)
if f and r.download_url:
urls[f] = r.download_url
labels.append(r.version or "")
if (r.release_notes or "").strip():
notes.append((r.release_notes or "").strip())
vn = next((x for x in labels if x), None) or "1.0"
if vn in used_labels:
suffix = "older" if release_type == "previous" else "alternate"
candidate = f"{vn} ({suffix})"
n = 2
while candidate in used_labels:
candidate = f"{vn} ({suffix} {n})"
n += 1
vn = candidate
used_labels.add(vn)
Version.objects.create(
sub_product_id=sub_id,
version=vn,
is_featured_stable=(release_type == "stable"),
windows_download_url=urls.get("windows_download_url", ""),
macos_download_url=urls.get("macos_download_url", ""),
linux_download_url=urls.get("linux_download_url", ""),
source_code_url="",
package_resource_url="",
release_notes="\n\n".join(dict.fromkeys(notes)),
is_active=any_active,
order=min(orders) if orders else 0,
)
def noop_reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("products", "0003_article_description_format_and_more"),
]
operations = [
migrations.AddField(
model_name="subproduct",
name="distribution",
field=models.CharField(
choices=[
("installable", "Installable application"),
("package", "Package (external / non-installable)"),
],
default="installable",
help_text="Only affects how releases appear on the public site.",
max_length=20,
),
),
migrations.CreateModel(
name="SubProductVersion",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("version", models.CharField(max_length=80)),
(
"is_featured_stable",
models.BooleanField(
default=False,
help_text="Highlighted as the main release on the product page.",
),
),
("windows_download_url", models.URLField(blank=True)),
("macos_download_url", models.URLField(blank=True)),
("linux_download_url", models.URLField(blank=True)),
("source_code_url", models.URLField(blank=True)),
(
"package_resource_url",
models.URLField(
blank=True,
help_text="For package-type modules: external link for this release.",
),
),
("release_notes", models.TextField(blank=True)),
("is_active", models.BooleanField(default=True)),
("order", models.PositiveIntegerField(default=0)),
(
"sub_product",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="versions",
to="products.subproduct",
),
),
],
options={
"verbose_name": "Release version",
"verbose_name_plural": "Release versions",
"ordering": ["order", "version", "pk"],
"unique_together": {("sub_product", "version")},
},
),
migrations.RunPython(migrate_legacy_releases_to_versions, noop_reverse),
migrations.DeleteModel(
name="SubProductRelease",
),
]
@@ -0,0 +1,22 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("products", "0004_distribution_and_versions"),
]
operations = [
migrations.AddField(
model_name="article",
name="badge",
field=models.CharField(
blank=True,
default="",
help_text="Optional label shown above the article title (e.g. 'Overview', 'Note').",
max_length=100,
),
preserve_default=False,
),
]
@@ -0,0 +1,49 @@
# Generated by Django 5.0.2 on 2026-05-17 14:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0005_article_badge'),
]
operations = [
migrations.AddField(
model_name='subproduct',
name='homepage_order',
field=models.PositiveIntegerField(default=0, help_text='Order within the homepage Products section.'),
),
migrations.AddField(
model_name='subproduct',
name='logo',
field=models.ImageField(blank=True, help_text='Small logo shown as a corner badge on homepage product cards.', null=True, upload_to='products/sub_logos/'),
),
migrations.AddField(
model_name='subproduct',
name='show_on_homepage',
field=models.BooleanField(default=False, help_text='Display this sub-product in the homepage Products section.'),
),
migrations.AlterField(
model_name='subproductversion',
name='package_resource_url',
field=models.URLField(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.'),
),
migrations.CreateModel(
name='ArticleCitation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('text', models.TextField(help_text='Full citation text.')),
('url', models.URLField(blank=True, help_text='Optional link to the cited source.')),
('order', models.PositiveIntegerField(default=0)),
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='citations', to='products.article')),
],
options={
'verbose_name': 'Article Citation',
'verbose_name_plural': 'Article Citations',
'ordering': ['order', 'pk'],
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2026-05-17 15:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0006_subproduct_logo_homepage_citations'),
]
operations = [
migrations.AddField(
model_name='article',
name='citation_count_display',
field=models.PositiveSmallIntegerField(blank=True, help_text='Optional number shown in the dashed citation circle badge at the bottom of the article card. Leave blank to hide the badge.', null=True),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.0.2 on 2026-05-20 12:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0007_article_citation_count_display'),
]
operations = [
migrations.AddField(
model_name='article',
name='citation_count_label',
field=models.CharField(blank=True, default='', help_text="Optional label shown below the number in the citation circle badge (e.g. 'cited'). Leave blank to show no label.", max_length=50),
),
migrations.AlterField(
model_name='article',
name='citation_count_display',
field=models.PositiveSmallIntegerField(blank=True, help_text='Optional number shown in the dashed citation circle badge. Leave blank to show an empty circle (if a label is set) or hide the badge entirely.', null=True),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 5.0.2 on 2026-05-23 10:11
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0008_article_citation_count_label'),
]
operations = [
migrations.AddField(
model_name='article',
name='main_product',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='articles', to='products.mainproduct'),
),
migrations.AlterField(
model_name='article',
name='sub_product',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='articles', to='products.subproduct'),
),
migrations.AddConstraint(
model_name='article',
constraint=models.CheckConstraint(check=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='article_exactly_one_parent'),
),
]
@@ -0,0 +1,45 @@
# Generated by Django 5.0.2 on 2026-05-23 10:31
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0009_article_main_product'),
]
operations = [
migrations.AlterUniqueTogether(
name='subproductversion',
unique_together=set(),
),
migrations.AddField(
model_name='mainproduct',
name='distribution',
field=models.CharField(choices=[('installable', 'Installable application'), ('package', 'Package (external / non-installable)')], default='installable', help_text='Only affects how releases appear on the public site.', max_length=20),
),
migrations.AddField(
model_name='subproductversion',
name='main_product',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='products.mainproduct'),
),
migrations.AlterField(
model_name='subproductversion',
name='sub_product',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='products.subproduct'),
),
migrations.AddConstraint(
model_name='subproductversion',
constraint=models.CheckConstraint(check=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='release_version_exactly_one_parent'),
),
migrations.AddConstraint(
model_name='subproductversion',
constraint=models.UniqueConstraint(condition=models.Q(('sub_product__isnull', False)), fields=('sub_product', 'version'), name='release_version_unique_sub_product_version'),
),
migrations.AddConstraint(
model_name='subproductversion',
constraint=models.UniqueConstraint(condition=models.Q(('main_product__isnull', False)), fields=('main_product', 'version'), name='release_version_unique_main_product_version'),
),
]
@@ -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",
),
]
+600
View File
@@ -0,0 +1,600 @@
import os
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
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 = (
("windows_download_url", "Windows", "images/icon-windows.svg"),
("macos_download_url", "macOS", "images/icon-macos.svg"),
("linux_download_url", "Linux", "images/icon-linux.svg"),
("source_code_url", "Source code", None),
)
DISTRIBUTION_INSTALLABLE = "installable"
DISTRIBUTION_PACKAGE = "package"
DISTRIBUTION_CHOICES = [
(DISTRIBUTION_INSTALLABLE, "Installable application"),
(DISTRIBUTION_PACKAGE, "Package (external / non-installable)"),
]
RELEASE_CHANNEL_STABLE = "stable"
RELEASE_CHANNEL_BETA = "beta"
RELEASE_CHANNEL_RC = "rc"
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):
name = models.CharField(max_length=200)
slug = models.SlugField(unique=True, blank=True)
short_description = models.CharField(max_length=300)
description = models.TextField()
description_format = models.CharField(
max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN
)
image = models.ImageField(upload_to="products/main/", blank=True, null=True)
distribution = models.CharField(
max_length=20,
choices=DISTRIBUTION_CHOICES,
default=DISTRIBUTION_INSTALLABLE,
help_text="Only affects how releases appear on the public site.",
)
package_resource_button_text = models.CharField(
max_length=100,
default="PyPI",
help_text="Label for the package resource button on package releases.",
)
package_source_button_text = models.CharField(
max_length=100,
default="Source",
help_text="Label for the source code button on package releases.",
)
downloads_section_link_url = models.URLField(
blank=True,
help_text="Optional link shown in the top-right corner of the downloads section.",
)
downloads_section_link_text = models.CharField(
max_length=100,
blank=True,
help_text="Label for the optional downloads section link.",
)
order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
show_on_homepage = models.BooleanField(default=False, help_text="Display this product in the homepage Products section.")
homepage_order = models.PositiveIntegerField(default=0, help_text="Order within the homepage Products section.")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["order", "name"]
verbose_name = "Main Product"
verbose_name_plural = "Main Products"
@property
def rendered_description(self):
return render_content(self.description, self.description_format)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse("products:main_product_detail", kwargs={"main_slug": self.slug})
def get_versions_archive_url(self):
return reverse(
"products:main_product_versions",
kwargs={"main_slug": self.slug},
)
class SubProduct(models.Model):
DISTRIBUTION_INSTALLABLE = DISTRIBUTION_INSTALLABLE
DISTRIBUTION_PACKAGE = DISTRIBUTION_PACKAGE
DISTRIBUTION_CHOICES = DISTRIBUTION_CHOICES
main_product = models.ForeignKey(
MainProduct,
on_delete=models.CASCADE,
related_name="sub_products",
)
name = models.CharField(max_length=200)
slug = models.SlugField(blank=True)
short_description = models.CharField(max_length=300)
description = models.TextField()
description_format = models.CharField(
max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN
)
image = models.ImageField(upload_to="products/sub/", blank=True, null=True)
logo = models.ImageField(upload_to="products/sub_logos/", blank=True, null=True, help_text="Small logo shown as a corner badge on homepage product cards.")
order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
show_on_homepage = models.BooleanField(default=False, help_text="Display this sub-product in the homepage Products section.")
homepage_order = models.PositiveIntegerField(default=0, help_text="Order within the homepage Products section.")
distribution = models.CharField(
max_length=20,
choices=DISTRIBUTION_CHOICES,
default=DISTRIBUTION_INSTALLABLE,
help_text="Only affects how releases appear on the public site.",
)
package_resource_button_text = models.CharField(
max_length=100,
default="PyPI",
help_text="Label for the package resource button on package releases.",
)
package_source_button_text = models.CharField(
max_length=100,
default="Source",
help_text="Label for the source code button on package releases.",
)
downloads_section_link_url = models.URLField(
blank=True,
help_text="Optional link shown in the top-right corner of the downloads section.",
)
downloads_section_link_text = models.CharField(
max_length=100,
blank=True,
help_text="Label for the optional downloads section link.",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["order", "name"]
unique_together = [["main_product", "slug"]]
verbose_name = "Sub Product"
verbose_name_plural = "Sub Products"
@property
def rendered_description(self):
return render_content(self.description, self.description_format)
def __str__(self):
return f"{self.main_product.name} {self.name}"
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse(
"products:sub_product_detail",
kwargs={
"main_slug": self.main_product.slug,
"sub_slug": self.slug,
},
)
def get_versions_archive_url(self):
return reverse(
"products:sub_product_versions",
kwargs={
"main_slug": self.main_product.slug,
"sub_slug": self.slug,
},
)
class Article(models.Model):
main_product = models.ForeignKey(
MainProduct,
on_delete=models.CASCADE,
related_name="articles",
null=True,
blank=True,
)
sub_product = models.ForeignKey(
SubProduct,
on_delete=models.CASCADE,
related_name="articles",
null=True,
blank=True,
)
badge = models.CharField(
max_length=100,
blank=True,
help_text="Optional label shown above the article title (e.g. 'Overview', 'Note').",
)
title = models.CharField(max_length=300)
description = models.TextField()
description_format = models.CharField(
max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN
)
citation_count_display = models.PositiveSmallIntegerField(
null=True,
blank=True,
help_text="Optional number shown in the dashed citation circle badge. Leave blank to show an empty circle (if a label is set) or hide the badge entirely.",
)
citation_count_label = models.CharField(
max_length=50,
blank=True,
default="",
help_text="Optional label shown below the number in the citation circle badge (e.g. 'cited'). Leave blank to show no label.",
)
order = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["order", "title"]
verbose_name = "Article"
verbose_name_plural = "Articles"
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="article_exactly_one_parent",
),
]
@property
def rendered_description(self):
return render_content(self.description, self.description_format)
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(
"An article must belong to exactly one main product or sub-product."
)
def __str__(self):
return self.title
class SubProductVersion(models.Model):
main_product = models.ForeignKey(
MainProduct,
on_delete=models.CASCADE,
related_name="versions",
null=True,
blank=True,
)
sub_product = models.ForeignKey(
SubProduct,
on_delete=models.CASCADE,
related_name="versions",
null=True,
blank=True,
)
version = models.CharField(max_length=80)
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,
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)
macos_download_url = models.URLField(blank=True)
linux_download_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(
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.",
)
release_notes = models.TextField(blank=True)
is_active = models.BooleanField(default=True)
order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["order", "version", "pk"]
verbose_name = "Release version"
verbose_name_plural = "Release versions"
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="release_version_exactly_one_parent",
),
models.UniqueConstraint(
fields=["sub_product", "version"],
condition=models.Q(sub_product__isnull=False),
name="release_version_unique_sub_product_version",
),
models.UniqueConstraint(
fields=["main_product", "version"],
condition=models.Q(main_product__isnull=False),
name="release_version_unique_main_product_version",
),
]
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 release version must belong to exactly one main product or sub-product."
)
def __str__(self):
parent = self.sub_product or self.main_product
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):
out = []
for field_name, label, icon in specs:
url = self.resolve_asset_url(field_name)
if url:
out.append(
{
"field": field_name,
"label": label,
"icon": icon,
"url": url,
"external": bool((getattr(self, field_name) or "").strip()),
}
)
return out
def has_any_install_asset(self):
return any(self.has_platform_asset(f[0]) 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):
return bool((self.package_resource_url or "").strip())
class ArticleSection(models.Model):
article = models.ForeignKey(
Article,
on_delete=models.CASCADE,
related_name="sections",
)
title = models.CharField(max_length=200)
value = models.TextField()
value_format = models.CharField(
max_length=20, choices=CONTENT_FORMAT_CHOICES, default=FORMAT_PLAIN
)
order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["order"]
verbose_name = "Article Section"
verbose_name_plural = "Article Sections"
@property
def rendered_value(self):
return render_content(self.value, self.value_format)
def __str__(self):
return f"{self.article.title} {self.title}"
class ArticleCitation(models.Model):
article = models.ForeignKey(
Article,
on_delete=models.CASCADE,
related_name="citations",
)
text = models.TextField(help_text="Full citation text.")
url = models.URLField(blank=True, help_text="Optional link to the cited source.")
order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["order", "pk"]
verbose_name = "Article Citation"
verbose_name_plural = "Article Citations"
def __str__(self):
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}"
+84
View File
@@ -0,0 +1,84 @@
import os
from django.core.files.storage import FileSystemStorage
from django.utils.text import get_valid_filename
class ReleaseFileStorage(FileSystemStorage):
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
+161
View File
@@ -0,0 +1,161 @@
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):
ordered = qs.order_by("order", "pk")
cand = ordered.filter(is_featured=True).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):
present = set()
for ver in version_list:
for field_name, *_ in INSTALLABLE_PLATFORM_SPECS:
if ver.has_platform_asset(field_name):
present.add(field_name)
return tuple(s for s in INSTALLABLE_PLATFORM_SPECS if s[0] in present)
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 = {
"distribution_installable": distribution == DISTRIBUTION_INSTALLABLE,
"distribution_package": distribution == DISTRIBUTION_PACKAGE,
"show_releases_section": False,
"featured_version": None,
"featured_channel": None,
"featured_install_cells": [],
"inline_download_channels": [],
"show_older_versions_link": False,
"package_versions": [],
**package_button_labels(product),
"downloads_section_link": downloads_section_link(product),
}
if distribution == DISTRIBUTION_INSTALLABLE:
featured = featured_version(active_versions)
if featured and not version_has_public_assets(featured):
for cand in active_versions.exclude(pk=featured.pk).order_by("order", "pk"):
if version_has_public_assets(cand):
featured = cand
break
context["featured_version"] = featured
if featured and version_has_public_assets(featured):
featured_block = build_installable_channel_block(featured)
if featured_block:
context["featured_channel"] = featured_block
context["featured_install_cells"] = featured_block["install_cells"]
context["show_releases_section"] = True
inline_blocks = []
for ver in active_versions.order_by("order", "pk"):
if featured and ver.pk == featured.pk:
continue
if not ver.show_on_product_page:
continue
block = build_installable_channel_block(ver)
if block:
inline_blocks.append(block)
context["inline_download_channels"] = inline_blocks
shown_pks = {featured.pk} if featured else set()
shown_pks.update(block["version"].pk for block in inline_blocks)
older_qs = active_versions.order_by("order", "pk")
if shown_pks:
older_qs = older_qs.exclude(pk__in=shown_pks)
context["show_older_versions_link"] = older_qs.exists()
elif distribution == DISTRIBUTION_PACKAGE:
pkg_versions = list(active_versions.order_by("order", "pk"))
context["package_versions"] = pkg_versions
context["show_releases_section"] = len(pkg_versions) > 0
return context
def build_archive_context(distribution, active_versions, product):
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(
active_versions.exclude(pk__in=exclude_pks).order_by("order", "pk")
if exclude_pks
else active_versions.order_by("order", "pk")
)
context = {
"featured_version": featured,
"archive_versions": archive,
**package_button_labels(product),
}
if distribution == DISTRIBUTION_INSTALLABLE:
specs = install_specs_from_versions(archive)
context["archive_specs"] = specs
archive_rows_installable = []
for ver in archive:
cells = []
for field_name, label, icon in specs:
cells.append(
{
"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})
context["archive_rows_installable"] = archive_rows_installable
context["archive_rows_package"] = False
context["distribution_installable"] = True
context["distribution_package"] = False
else:
context["archive_specs"] = ()
context["archive_rows_installable"] = []
context["archive_rows_package"] = True
context["distribution_installable"] = False
context["distribution_package"] = True
return context
+37
View File
@@ -0,0 +1,37 @@
from django.db.models.signals import post_delete, pre_save
from django.dispatch import receiver
from .models import SubProductVersion
from .release_assets import RELEASE_FILE_BY_URL_FIELD
def _stored_release_file_name(version, file_field):
release_file = getattr(version, file_field)
return release_file.name if release_file else ""
def _delete_release_file_from_disk(version, file_field):
release_file = getattr(version, file_field)
if release_file:
release_file.delete(save=False)
@receiver(pre_save, sender=SubProductVersion)
def delete_replaced_or_cleared_release_files(sender, instance, **kwargs):
if not instance.pk:
return
try:
previous = SubProductVersion.objects.get(pk=instance.pk)
except SubProductVersion.DoesNotExist:
return
for file_field in RELEASE_FILE_BY_URL_FIELD.values():
old_name = _stored_release_file_name(previous, file_field)
new_name = _stored_release_file_name(instance, file_field)
if old_name and old_name != new_name:
_delete_release_file_from_disk(previous, file_field)
@receiver(post_delete, sender=SubProductVersion)
def delete_release_files_on_version_delete(sender, instance, **kwargs):
for file_field in RELEASE_FILE_BY_URL_FIELD.values():
_delete_release_file_from_disk(instance, file_field)
View File
+77
View File
@@ -0,0 +1,77 @@
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct
class AdminAccessTest(TestCase):
def setUp(self):
self.superuser = User.objects.create_superuser(
username="admin",
email="admin@example.com",
password="securepassword123",
)
self.client.login(username="admin", password="securepassword123")
self.main_product = MainProduct.objects.create(
name="Test Product",
short_description="Short",
description="Description",
)
self.sub_product = SubProduct.objects.create(
main_product=self.main_product,
name="Sub Product",
short_description="Short sub",
description="Sub description",
)
self.article = Article.objects.create(
sub_product=self.sub_product,
title="Test Article",
description="Article body",
)
self.section = ArticleSection.objects.create(
article=self.article,
title="Link",
value="https://example.com",
)
def test_main_product_changelist_accessible(self):
url = reverse("admin:products_mainproduct_changelist")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_sub_product_changelist_accessible(self):
url = reverse("admin:products_subproduct_changelist")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_article_changelist_accessible(self):
url = reverse("admin:products_article_changelist")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_article_section_changelist_accessible(self):
url = reverse("admin:products_articlesection_changelist")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_main_product_change_accessible(self):
url = reverse("admin:products_mainproduct_change", args=[self.main_product.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_sub_product_change_accessible(self):
url = reverse("admin:products_subproduct_change", args=[self.sub_product.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_article_change_accessible(self):
url = reverse("admin:products_article_change", args=[self.article.pk])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_admin_requires_authentication(self):
self.client.logout()
url = reverse("admin:products_mainproduct_changelist")
response = self.client.get(url)
self.assertNotEqual(response.status_code, 200)
+258
View File
@@ -0,0 +1,258 @@
from django.test import TestCase
from django.urls import reverse
from django.core.exceptions import ValidationError
from apps.products.models import (
Article,
ArticleSection,
MainProduct,
SubProduct,
SubProductVersion,
)
class MainProductModelTest(TestCase):
def setUp(self):
self.product = MainProduct.objects.create(
name="Test Product",
short_description="Short description",
description="Full description of the test product.",
)
def test_str_representation(self):
self.assertEqual(str(self.product), "Test Product")
def test_slug_auto_generated_on_create(self):
self.assertEqual(self.product.slug, "test-product")
def test_slug_not_overwritten_on_update(self):
self.product.name = "Changed Name"
self.product.save()
self.assertEqual(self.product.slug, "test-product")
def test_default_order_is_zero(self):
self.assertEqual(self.product.order, 0)
def test_default_is_active_is_true(self):
self.assertTrue(self.product.is_active)
def test_get_absolute_url(self):
url = self.product.get_absolute_url()
self.assertEqual(url, reverse("products:main_product_detail", kwargs={"main_slug": "test-product"}))
def test_slug_uniqueness(self):
from django.db import IntegrityError
with self.assertRaises(IntegrityError):
MainProduct.objects.create(
name="Test Product",
slug="test-product",
short_description="Another",
description="Another",
)
def test_timestamps_set_on_create(self):
self.assertIsNotNone(self.product.created_at)
self.assertIsNotNone(self.product.updated_at)
class SubProductModelTest(TestCase):
def setUp(self):
self.main_product = MainProduct.objects.create(
name="Main Product",
short_description="Short",
description="Description",
)
self.sub_product = SubProduct.objects.create(
main_product=self.main_product,
name="Sub Product",
short_description="Sub short",
description="Sub description.",
)
def test_str_representation(self):
self.assertIn("Main Product", str(self.sub_product))
self.assertIn("Sub Product", str(self.sub_product))
def test_slug_auto_generated(self):
self.assertEqual(self.sub_product.slug, "sub-product")
def test_get_absolute_url(self):
url = self.sub_product.get_absolute_url()
expected = reverse(
"products:sub_product_detail",
kwargs={"main_slug": "main-product", "sub_slug": "sub-product"},
)
self.assertEqual(url, expected)
def test_cascade_delete_with_main_product(self):
main_pk = self.main_product.pk
sub_pk = self.sub_product.pk
self.main_product.delete()
self.assertFalse(SubProduct.objects.filter(pk=sub_pk).exists())
self.assertFalse(MainProduct.objects.filter(pk=main_pk).exists())
class ArticleModelTest(TestCase):
def setUp(self):
self.main_product = MainProduct.objects.create(
name="Main",
short_description="Short",
description="Desc",
)
self.sub_product = SubProduct.objects.create(
main_product=self.main_product,
name="Sub",
short_description="Short",
description="Desc",
)
self.article = Article.objects.create(
sub_product=self.sub_product,
title="Test Article",
description="Article body.",
)
def test_str_representation(self):
self.assertEqual(str(self.article), "Test Article")
def test_cascade_delete_with_sub_product(self):
article_pk = self.article.pk
self.sub_product.delete()
self.assertFalse(Article.objects.filter(pk=article_pk).exists())
def test_main_product_article(self):
article = Article.objects.create(
main_product=self.main_product,
title="Product Article",
description="Body.",
)
self.assertEqual(article.main_product, self.main_product)
self.assertIsNone(article.sub_product_id)
def test_cascade_delete_with_main_product(self):
article = Article.objects.create(
main_product=self.main_product,
title="Product Article",
description="Body.",
)
article_pk = article.pk
self.main_product.delete()
self.assertFalse(Article.objects.filter(pk=article_pk).exists())
def test_requires_exactly_one_parent(self):
article = Article(
main_product=self.main_product,
sub_product=self.sub_product,
title="Invalid",
description="Body.",
)
with self.assertRaises(ValidationError):
article.full_clean()
def test_requires_a_parent(self):
article = Article(title="Orphan", description="Body.")
with self.assertRaises(ValidationError):
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):
def setUp(self):
self.main_product = MainProduct.objects.create(
name="Main",
short_description="Short",
description="Desc",
)
self.sub_product = SubProduct.objects.create(
main_product=self.main_product,
name="Sub",
short_description="Short",
description="Desc",
)
def test_main_product_version(self):
version = SubProductVersion.objects.create(
main_product=self.main_product,
version="1.0.0",
)
self.assertEqual(version.main_product, self.main_product)
self.assertIsNone(version.sub_product_id)
def test_requires_exactly_one_parent(self):
version = SubProductVersion(
main_product=self.main_product,
sub_product=self.sub_product,
version="1.0.0",
)
with self.assertRaises(ValidationError):
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):
def setUp(self):
main = MainProduct.objects.create(name="M", short_description="s", description="d")
sub = SubProduct.objects.create(main_product=main, name="S", short_description="s", description="d")
self.article = Article.objects.create(sub_product=sub, title="Article", description="Desc")
self.section = ArticleSection.objects.create(
article=self.article,
title="Link",
value="https://example.com",
)
def test_str_representation(self):
self.assertIn("Article", str(self.section))
self.assertIn("Link", str(self.section))
def test_cascade_delete_with_article(self):
section_pk = self.section.pk
self.article.delete()
self.assertFalse(ArticleSection.objects.filter(pk=section_pk).exists())
+350
View File
@@ -0,0 +1,350 @@
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 apps.products.models import Article, ArticleSection, MainProduct, SubProduct, SubProductVersion
class ProductViewsSetup(TestCase):
def setUp(self):
self.main_product = MainProduct.objects.create(
name="Tecvico",
slug="tecvico",
short_description="Radiomics software",
description="Full description.",
)
self.sub_product = SubProduct.objects.create(
main_product=self.main_product,
name="Image Processing",
slug="image-processing",
short_description="Filtering and registration",
description="Sub description.",
)
self.article = Article.objects.create(
sub_product=self.sub_product,
title="Filtering",
description="Article body.",
)
ArticleSection.objects.create(
article=self.article,
title="Standard",
value="IBSI 2.0",
)
class ProductOverviewViewTest(ProductViewsSetup):
def test_overview_returns_200(self):
response = self.client.get(reverse("products:overview"))
self.assertEqual(response.status_code, 200)
def test_overview_uses_correct_template(self):
response = self.client.get(reverse("products:overview"))
self.assertTemplateUsed(response, "products/overview.html")
def test_overview_contains_main_product(self):
response = self.client.get(reverse("products:overview"))
self.assertIn(self.main_product, response.context["main_products"])
def test_inactive_product_excluded(self):
inactive = MainProduct.objects.create(
name="Inactive",
short_description="s",
description="d",
is_active=False,
)
response = self.client.get(reverse("products:overview"))
self.assertNotIn(inactive, response.context["main_products"])
class MainProductDetailViewTest(ProductViewsSetup):
def test_detail_returns_200(self):
url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_detail_uses_correct_template(self):
url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"})
response = self.client.get(url)
self.assertTemplateUsed(response, "products/main_detail.html")
def test_detail_contains_product_in_context(self):
url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"})
response = self.client.get(url)
self.assertEqual(response.context["main_product"], self.main_product)
def test_nonexistent_slug_returns_404(self):
url = reverse("products:main_product_detail", kwargs={"main_slug": "nonexistent"})
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
def test_inactive_product_returns_404(self):
self.main_product.is_active = False
self.main_product.save()
url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"})
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
def test_detail_articles_in_context(self):
main_article = Article.objects.create(
main_product=self.main_product,
title="Overview",
description="Main product article.",
)
url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"})
response = self.client.get(url)
self.assertIn("articles", response.context)
self.assertIn(main_article, list(response.context["articles"]))
self.assertNotIn(self.article, list(response.context["articles"]))
class SubProductDetailViewTest(ProductViewsSetup):
def test_sub_detail_returns_200(self):
url = reverse(
"products:sub_product_detail",
kwargs={"main_slug": "tecvico", "sub_slug": "image-processing"},
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_sub_detail_uses_correct_template(self):
url = reverse(
"products:sub_product_detail",
kwargs={"main_slug": "tecvico", "sub_slug": "image-processing"},
)
response = self.client.get(url)
self.assertTemplateUsed(response, "products/sub_detail.html")
def test_sub_detail_context_keys(self):
url = reverse(
"products:sub_product_detail",
kwargs={"main_slug": "tecvico", "sub_slug": "image-processing"},
)
response = self.client.get(url)
self.assertIn("main_product", response.context)
self.assertIn("sub_product", response.context)
self.assertIn("articles", response.context)
self.assertIn("siblings", response.context)
def test_sub_detail_articles_in_context(self):
url = reverse(
"products:sub_product_detail",
kwargs={"main_slug": "tecvico", "sub_slug": "image-processing"},
)
response = self.client.get(url)
self.assertIn(self.article, list(response.context["articles"]))
def test_nonexistent_sub_slug_returns_404(self):
url = reverse(
"products:sub_product_detail",
kwargs={"main_slug": "tecvico", "sub_slug": "does-not-exist"},
)
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
def test_mismatched_main_slug_returns_404(self):
url = reverse(
"products:sub_product_detail",
kwargs={"main_slug": "wrong-product", "sub_slug": "image-processing"},
)
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
class SubProductOlderVersionsViewTest(ProductViewsSetup):
def test_versions_returns_200(self):
from apps.products.models import SubProductVersion
SubProductVersion.objects.create(
sub_product=self.sub_product,
version="9.9",
is_featured=True,
release_channel="stable",
windows_download_url="https://example.com/w",
is_active=True,
)
SubProductVersion.objects.create(
sub_product=self.sub_product,
version="9.8",
is_featured=False,
windows_download_url="https://example.com/w2",
is_active=True,
)
url = reverse(
"products:sub_product_versions",
kwargs={"main_slug": "tecvico", "sub_slug": "image-processing"},
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "products/versions_archive.html")
def test_main_versions_returns_200(self):
from apps.products.models import SubProductVersion
SubProductVersion.objects.create(
main_product=self.main_product,
version="9.9",
is_featured=True,
release_channel="stable",
windows_download_url="https://example.com/win.exe",
is_active=True,
)
SubProductVersion.objects.create(
main_product=self.main_product,
version="9.8",
is_active=True,
)
url = reverse(
"products:main_product_versions",
kwargs={"main_slug": "tecvico"},
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "products/versions_archive.html")
def test_main_detail_releases_in_context(self):
from apps.products.models import SubProductVersion
SubProductVersion.objects.create(
main_product=self.main_product,
version="1.0",
windows_download_url="https://example.com/win.exe",
is_active=True,
)
url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"})
response = self.client.get(url)
self.assertTrue(response.context["show_releases_section"])
def test_installable_shows_release_channel_badges(self):
from apps.products.models import RELEASE_CHANNEL_BETA, RELEASE_CHANNEL_PREVIOUS, SubProductVersion
SubProductVersion.objects.create(
main_product=self.main_product,
version="2.0",
release_channel=RELEASE_CHANNEL_BETA,
is_featured=True,
windows_download_url="https://example.com/beta.exe",
is_active=True,
)
SubProductVersion.objects.create(
main_product=self.main_product,
version="1.9",
release_channel=RELEASE_CHANNEL_PREVIOUS,
show_on_product_page=True,
windows_download_url="https://example.com/prev.exe",
is_active=True,
)
url = reverse("products:main_product_detail", kwargs={"main_slug": "tecvico"})
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": "tecvico"})
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": "tecvico"})
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))
+34
View File
@@ -0,0 +1,34 @@
from django.urls import path
from . import views
app_name = "products"
urlpatterns = [
path("", views.ProductOverviewView.as_view(), name="overview"),
path(
"releases/<int:version_id>/<slug:asset>/download/",
views.ReleaseAssetDownloadView.as_view(),
name="release_asset_download",
),
path(
"<slug:main_slug>/",
views.MainProductDetailView.as_view(),
name="main_product_detail",
),
path(
"<slug:main_slug>/versions/",
views.MainProductOlderVersionsView.as_view(),
name="main_product_versions",
),
path(
"<slug:main_slug>/<slug:sub_slug>/versions/",
views.SubProductOlderVersionsView.as_view(),
name="sub_product_versions",
),
path(
"<slug:main_slug>/<slug:sub_slug>/",
views.SubProductDetailView.as_view(),
name="sub_product_detail",
),
]
+155
View File
@@ -0,0 +1,155 @@
import mimetypes
from django.http import FileResponse, Http404
from django.shortcuts import get_object_or_404
from django.views import View
from django.views.generic import DetailView, ListView, TemplateView
from .models import Article, ArticleCitation, ArticleSection, MainProduct, ProductVideo, SubProduct, SubProductVersion
from .release_assets import FILE_FIELD_BY_ASSET_KEY, RELEASE_ASSET_KEYS, URL_FIELD_BY_ASSET_KEY
from .release_context import build_archive_context, build_release_context
class ReleaseAssetDownloadView(View):
def get(self, request, version_id, asset):
if asset not in RELEASE_ASSET_KEYS:
raise Http404
version = get_object_or_404(SubProductVersion, pk=version_id, is_active=True)
parent = version.sub_product or version.main_product
if parent is None or not parent.is_active:
raise Http404
url_field = URL_FIELD_BY_ASSET_KEY[asset]
if (getattr(version, url_field) or "").strip():
raise Http404
file_field = FILE_FIELD_BY_ASSET_KEY[asset]
release_file = getattr(version, file_field)
if not release_file:
raise Http404
filename = version.release_download_filename(asset)
content_type, _ = mimetypes.guess_type(filename)
return FileResponse(
release_file.open("rb"),
as_attachment=True,
filename=filename,
content_type=content_type or "application/octet-stream",
)
class ProductOverviewView(ListView):
model = MainProduct
template_name = "products/overview.html"
context_object_name = "main_products"
queryset = MainProduct.objects.filter(is_active=True).prefetch_related(
"sub_products"
)
class MainProductDetailView(DetailView):
model = MainProduct
template_name = "products/main_detail.html"
context_object_name = "main_product"
slug_url_kwarg = "main_slug"
queryset = MainProduct.objects.filter(is_active=True).prefetch_related(
"sub_products"
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["articles"] = self.object.articles.prefetch_related(
"sections", "citations"
).all()
context["product_videos"] = self.object.videos.filter(is_active=True).order_by("order")
release_context = build_release_context(
self.object.distribution,
self.object.versions.filter(is_active=True),
self.object,
)
release_context["versions_archive_url"] = self.object.get_versions_archive_url()
context.update(release_context)
return context
class MainProductOlderVersionsView(TemplateView):
template_name = "products/versions_archive.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
main_product = get_object_or_404(
MainProduct,
slug=self.kwargs["main_slug"],
is_active=True,
)
active_versions = main_product.versions.filter(is_active=True)
context["main_product"] = main_product
context["sub_product"] = None
context["product_detail_url"] = main_product.get_absolute_url()
context.update(
build_archive_context(
main_product.distribution, active_versions, main_product
)
)
return context
class SubProductDetailView(TemplateView):
template_name = "products/sub_detail.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
main_product = get_object_or_404(
MainProduct,
slug=self.kwargs["main_slug"],
is_active=True,
)
sub_product = get_object_or_404(
SubProduct,
slug=self.kwargs["sub_slug"],
main_product=main_product,
is_active=True,
)
context["main_product"] = main_product
context["sub_product"] = sub_product
context["articles"] = sub_product.articles.prefetch_related(
"sections", "citations"
).all()
context["product_videos"] = sub_product.videos.filter(is_active=True).order_by("order")
context["siblings"] = (
SubProduct.objects.filter(main_product=main_product, is_active=True)
.exclude(pk=sub_product.pk)
.order_by("order", "name")
)
active_versions = sub_product.versions.filter(is_active=True)
release_context = build_release_context(
sub_product.distribution, active_versions, sub_product
)
release_context["versions_archive_url"] = sub_product.get_versions_archive_url()
context.update(release_context)
return context
class SubProductOlderVersionsView(TemplateView):
template_name = "products/versions_archive.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
main_product = get_object_or_404(
MainProduct,
slug=self.kwargs["main_slug"],
is_active=True,
)
sub_product = get_object_or_404(
SubProduct,
slug=self.kwargs["sub_slug"],
main_product=main_product,
is_active=True,
)
active_versions = sub_product.versions.filter(is_active=True)
context["main_product"] = main_product
context["sub_product"] = sub_product
context["product_detail_url"] = sub_product.get_absolute_url()
context.update(
build_archive_context(
sub_product.distribution, active_versions, sub_product
)
)
return context
View File
+7
View File
@@ -0,0 +1,7 @@
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
application = get_asgi_application()
View File
+134
View File
@@ -0,0 +1,134 @@
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent
SECRET_KEY = os.environ.get(
"DJANGO_SECRET_KEY",
"django-insecure-base-key-override-in-production",
)
DEBUG = False
ALLOWED_HOSTS = []
DJANGO_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"whitenoise.runserver_nostatic",
"django.contrib.staticfiles",
]
LOCAL_APPS = [
"apps.core",
"apps.products",
"apps.pages",
]
INSTALLED_APPS = DJANGO_APPS + LOCAL_APPS
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"apps.core.context_processors.site_branding",
"apps.core.context_processors.site_contact",
"apps.core.context_processors.navigation",
],
},
},
]
WSGI_APPLICATION = "config.wsgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("POSTGRES_DB", "tecvico"),
"USER": os.environ.get("POSTGRES_USER", "tecvico_user"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""),
"HOST": os.environ.get("POSTGRES_HOST", "localhost"),
"PORT": os.environ.get("POSTGRES_PORT", "5432"),
"CONN_MAX_AGE": 60,
"OPTIONS": {
"connect_timeout": 10,
},
}
}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "static"]
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
MEDIA_URL = "/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"
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose",
},
},
"root": {
"handlers": ["console"],
"level": "WARNING",
},
"loggers": {
"django": {
"handlers": ["console"],
"level": os.environ.get("DJANGO_LOG_LEVEL", "WARNING"),
"propagate": False,
},
},
}
+37
View File
@@ -0,0 +1,37 @@
import os
from dotenv import load_dotenv
load_dotenv()
from .base import * # noqa: F401, F403, E402
DEBUG = True
SECRET_KEY = os.environ.get(
"DJANGO_SECRET_KEY",
"django-insecure-development-key-not-for-production-use",
)
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0").split(",")
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage"
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {"class": "logging.StreamHandler"},
},
"root": {
"handlers": ["console"],
"level": "DEBUG",
},
"loggers": {
"django": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
},
}
+25
View File
@@ -0,0 +1,25 @@
import os
from .base import * # noqa: F401, F403
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = False
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "").split(",")
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_SSL_REDIRECT = os.environ.get("SECURE_SSL_REDIRECT", "True") == "True"
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"
CSRF_TRUSTED_ORIGINS = [
origin.strip()
for origin in os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",")
if origin.strip()
]
+3
View File
@@ -0,0 +1,3 @@
from .development import * # noqa: F401, F403
DATABASES["default"]["CONN_MAX_AGE"] = 0 # noqa: F405
+19
View File
@@ -0,0 +1,19 @@
import os
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
admin.site.site_header = "Tecvico Administration"
admin.site.site_title = "Tecvico Admin"
admin.site.index_title = "Welcome to Tecvico Administration"
urlpatterns = [
path("admin/", admin.site.urls),
path("products/", include("apps.products.urls", namespace="products")),
path("", include("apps.pages.urls", namespace="pages")),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+7
View File
@@ -0,0 +1,7 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
application = get_wsgi_application()

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