initial commit
This commit is contained in:
@@ -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/
|
||||||
@@ -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
@@ -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
@@ -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", "-"]
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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>")
|
||||||
@@ -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.",
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -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")}),
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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/'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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/'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)'}"
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.test import Client, TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from apps.pages.contact_uploads import (
|
||||||
|
MAX_ATTACHMENTS,
|
||||||
|
MAX_FILE_SIZE,
|
||||||
|
validate_contact_attachments,
|
||||||
|
)
|
||||||
|
from apps.pages.models import ContactSubmission, ContactSubmissionAttachment
|
||||||
|
|
||||||
|
|
||||||
|
def _png_file(name="screenshot.png"):
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("RGB", (8, 8), color="red").save(buffer, format="PNG")
|
||||||
|
buffer.seek(0)
|
||||||
|
return SimpleUploadedFile(name, buffer.read(), content_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_file(name="logs.zip", entries=None):
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
for entry_name, content in (entries or {"app.log": "line one\n"}).items():
|
||||||
|
archive.writestr(entry_name, content)
|
||||||
|
buffer.seek(0)
|
||||||
|
return SimpleUploadedFile(name, buffer.read(), content_type="application/zip")
|
||||||
|
|
||||||
|
|
||||||
|
class ContactUploadValidationTest(TestCase):
|
||||||
|
def test_accepts_valid_png(self):
|
||||||
|
validated = validate_contact_attachments([_png_file()])
|
||||||
|
self.assertEqual(len(validated), 1)
|
||||||
|
self.assertEqual(validated[0][1], "screenshot.png")
|
||||||
|
|
||||||
|
def test_accepts_valid_zip(self):
|
||||||
|
validated = validate_contact_attachments([_zip_file()])
|
||||||
|
self.assertEqual(len(validated), 1)
|
||||||
|
|
||||||
|
def test_accepts_valid_text_log(self):
|
||||||
|
uploaded = SimpleUploadedFile(
|
||||||
|
"error.log",
|
||||||
|
b"2026-06-07 ERROR something failed\n",
|
||||||
|
content_type="text/plain",
|
||||||
|
)
|
||||||
|
validated = validate_contact_attachments([uploaded])
|
||||||
|
self.assertEqual(validated[0][1], "error.log")
|
||||||
|
|
||||||
|
def test_rejects_executable_extension(self):
|
||||||
|
uploaded = SimpleUploadedFile(
|
||||||
|
"malware.exe",
|
||||||
|
b"MZfake",
|
||||||
|
content_type="application/octet-stream",
|
||||||
|
)
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
validate_contact_attachments([uploaded])
|
||||||
|
|
||||||
|
def test_rejects_php_disguised_as_png(self):
|
||||||
|
uploaded = SimpleUploadedFile(
|
||||||
|
"image.png",
|
||||||
|
b"<?php echo 'bad'; ?>",
|
||||||
|
content_type="image/png",
|
||||||
|
)
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
validate_contact_attachments([uploaded])
|
||||||
|
|
||||||
|
def test_rejects_oversized_file(self):
|
||||||
|
uploaded = SimpleUploadedFile(
|
||||||
|
"big.log",
|
||||||
|
b"x" * (MAX_FILE_SIZE + 1),
|
||||||
|
content_type="text/plain",
|
||||||
|
)
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
validate_contact_attachments([uploaded])
|
||||||
|
|
||||||
|
def test_rejects_too_many_files(self):
|
||||||
|
files = [_png_file(f"shot-{index}.png") for index in range(MAX_ATTACHMENTS + 1)]
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
validate_contact_attachments(files)
|
||||||
|
|
||||||
|
def test_rejects_zip_with_path_traversal(self):
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buffer, "w") as archive:
|
||||||
|
archive.writestr("../escape.txt", "bad")
|
||||||
|
buffer.seek(0)
|
||||||
|
uploaded = SimpleUploadedFile(
|
||||||
|
"bad.zip",
|
||||||
|
buffer.read(),
|
||||||
|
content_type="application/zip",
|
||||||
|
)
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
validate_contact_attachments([uploaded])
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CONTACT_UPLOAD_ROOT=__import__("pathlib").Path(__file__).resolve().parents[3]
|
||||||
|
/ "test_private_uploads"
|
||||||
|
)
|
||||||
|
class ContactViewUploadTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client(enforce_csrf_checks=True)
|
||||||
|
self.url = reverse("pages:contact")
|
||||||
|
|
||||||
|
def _start_session(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.csrf_token = response.cookies["csrftoken"].value
|
||||||
|
captcha_question = response.context["captcha_question"]
|
||||||
|
left, right = captcha_question.split(" + ")
|
||||||
|
return int(left) + int(right)
|
||||||
|
|
||||||
|
def _post_contact(self, captcha_answer, attachments=None, extra=None):
|
||||||
|
payload = {
|
||||||
|
"name": "Test User",
|
||||||
|
"title": "Upload test",
|
||||||
|
"description": "Testing attachments",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"captcha_answer": captcha_answer,
|
||||||
|
}
|
||||||
|
if attachments is not None:
|
||||||
|
payload["attachments"] = attachments
|
||||||
|
if extra:
|
||||||
|
payload.update(extra)
|
||||||
|
return self.client.post(
|
||||||
|
self.url,
|
||||||
|
data=payload,
|
||||||
|
HTTP_X_CSRFTOKEN=self.csrf_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_contact_submission_with_png_attachment(self):
|
||||||
|
captcha_answer = self._start_session()
|
||||||
|
response = self._post_contact(captcha_answer, attachments=_png_file())
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(response.json()["success"])
|
||||||
|
submission = ContactSubmission.objects.get(title="Upload test")
|
||||||
|
self.assertEqual(submission.attachments.count(), 1)
|
||||||
|
attachment = submission.attachments.first()
|
||||||
|
self.assertEqual(attachment.original_filename, "screenshot.png")
|
||||||
|
self.assertTrue(attachment.file.storage.exists(attachment.file.name))
|
||||||
|
|
||||||
|
def test_contact_submission_rejects_invalid_attachment(self):
|
||||||
|
captcha_answer = self._start_session()
|
||||||
|
response = self._post_contact(
|
||||||
|
captcha_answer,
|
||||||
|
attachments=SimpleUploadedFile(
|
||||||
|
"bad.exe",
|
||||||
|
b"MZ",
|
||||||
|
content_type="application/octet-stream",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertFalse(response.json()["success"])
|
||||||
|
self.assertIn("attachments", response.json()["errors"])
|
||||||
|
self.assertEqual(ContactSubmission.objects.count(), 0)
|
||||||
|
self.assertEqual(ContactSubmissionAttachment.objects.count(), 0)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.pages.models import CustomPage, CustomPageSection, CustomPageSectionItem
|
||||||
|
|
||||||
|
|
||||||
|
class CustomPageViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.page = CustomPage.objects.create(
|
||||||
|
title="Resources",
|
||||||
|
slug="resources",
|
||||||
|
show_in_nav=True,
|
||||||
|
menu_order=5,
|
||||||
|
is_published=True,
|
||||||
|
)
|
||||||
|
CustomPageSection.objects.create(
|
||||||
|
page=self.page,
|
||||||
|
section_type=CustomPageSection.TYPE_HERO,
|
||||||
|
title="Resources",
|
||||||
|
badge="Docs",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
CustomPage.objects.create(
|
||||||
|
title="Draft Page",
|
||||||
|
slug="draft",
|
||||||
|
is_published=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_published_page_returns_200(self):
|
||||||
|
response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "resources"}))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTemplateUsed(response, "pages/custom_page.html")
|
||||||
|
self.assertEqual(response.context["custom_page"], self.page)
|
||||||
|
|
||||||
|
def test_unpublished_page_returns_404(self):
|
||||||
|
response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "draft"}))
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_unknown_slug_returns_404(self):
|
||||||
|
response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "missing"}))
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_section_item_with_url_renders_as_link(self):
|
||||||
|
section = CustomPageSection.objects.create(
|
||||||
|
page=self.page,
|
||||||
|
section_type=CustomPageSection.TYPE_FEATURES,
|
||||||
|
title="Highlights",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
CustomPageSectionItem.objects.create(
|
||||||
|
page=self.page,
|
||||||
|
section=section,
|
||||||
|
title="Documentation",
|
||||||
|
content="Read the docs.",
|
||||||
|
url="/about/",
|
||||||
|
order=1,
|
||||||
|
)
|
||||||
|
response = self.client.get(reverse("pages:custom_page", kwargs={"slug": "resources"}))
|
||||||
|
self.assertContains(response, 'href="/about/"')
|
||||||
|
self.assertContains(response, "section-item-link")
|
||||||
|
self.assertContains(response, "Documentation")
|
||||||
|
|
||||||
|
|
||||||
|
class CustomPageNavTest(TestCase):
|
||||||
|
def test_nav_custom_pages_in_context(self):
|
||||||
|
CustomPage.objects.create(
|
||||||
|
title="Visible",
|
||||||
|
slug="visible",
|
||||||
|
show_in_nav=True,
|
||||||
|
menu_order=1,
|
||||||
|
is_published=True,
|
||||||
|
)
|
||||||
|
CustomPage.objects.create(
|
||||||
|
title="Hidden Nav",
|
||||||
|
slug="hidden-nav",
|
||||||
|
show_in_nav=False,
|
||||||
|
is_published=True,
|
||||||
|
)
|
||||||
|
response = self.client.get(reverse("pages:home"))
|
||||||
|
pages = list(response.context["nav_custom_pages"])
|
||||||
|
self.assertEqual(len(pages), 1)
|
||||||
|
self.assertEqual(pages[0].slug, "visible")
|
||||||
|
|
||||||
|
def test_nav_link_rendered(self):
|
||||||
|
CustomPage.objects.create(
|
||||||
|
title="Team",
|
||||||
|
slug="team",
|
||||||
|
menu_label="Our Team",
|
||||||
|
show_in_nav=True,
|
||||||
|
is_published=True,
|
||||||
|
)
|
||||||
|
response = self.client.get(reverse("pages:home"))
|
||||||
|
self.assertContains(response, "Our Team")
|
||||||
|
self.assertContains(response, reverse("pages:custom_page", kwargs={"slug": "team"}))
|
||||||
@@ -0,0 +1,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>")
|
||||||
@@ -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}",
|
||||||
|
)
|
||||||
@@ -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"),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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 "—"
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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}"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
@@ -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))
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .development import * # noqa: F401, F403
|
||||||
|
|
||||||
|
DATABASES["default"]["CONN_MAX_AGE"] = 0 # noqa: F405
|
||||||
@@ -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)
|
||||||
@@ -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
Reference in New Issue
Block a user