feat: Dockerize and complete project
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,12 @@
|
|||||||
|
DJANGO_SETTINGS_MODULE=config.settings.development
|
||||||
|
DJANGO_SECRET_KEY=django-insecure-replace-this-with-a-real-secret-key-in-production
|
||||||
|
DEBUG=True
|
||||||
|
ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
|
||||||
|
|
||||||
|
POSTGRES_DB=tecvico
|
||||||
|
POSTGRES_USER=tecvico_user
|
||||||
|
POSTGRES_PASSWORD=tecvico_password
|
||||||
|
POSTGRES_HOST=db
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
|
SECURE_SSL_REDIRECT=False
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
__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/
|
||||||
|
staticfiles/
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
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 . .
|
||||||
|
|
||||||
|
RUN python manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
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", "-"]
|
||||||
@@ -1,2 +1,278 @@
|
|||||||
# Radiuma_Website
|
# Tecvico Website
|
||||||
|
|
||||||
|
Django MVT informational website for **Tecvico Corp** (formerly Visera), showcasing the **ViSERA** medical imaging and radiomics software suite.
|
||||||
|
|
||||||
|
## 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 (dark glass/ice 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` | What is 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:
|
||||||
|
|
||||||
|
- **ViSERA** (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 Corp.
|
||||||
|
|||||||
@@ -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,10 @@
|
|||||||
|
from apps.products.models import MainProduct
|
||||||
|
|
||||||
|
|
||||||
|
def navigation(request):
|
||||||
|
main_products = (
|
||||||
|
MainProduct.objects.filter(is_active=True)
|
||||||
|
.prefetch_related("sub_products")
|
||||||
|
.order_by("order", "name")
|
||||||
|
)
|
||||||
|
return {"nav_main_products": main_products}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from apps.pages.models import DownloadItem, FAQEntry
|
||||||
|
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct
|
||||||
|
|
||||||
|
MAIN_PRODUCTS = [
|
||||||
|
{
|
||||||
|
"name": "ViSERA",
|
||||||
|
"slug": "visera",
|
||||||
|
"short_description": "Visualized & Standardized Environment for Radiomics Analysis",
|
||||||
|
"description": (
|
||||||
|
"ViSERA 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. "
|
||||||
|
"ViSERA 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,
|
||||||
|
"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. ViSERA 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": (
|
||||||
|
"ViSERA 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 Corp R&D Team", "order": 3},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Image Registration & Fusion",
|
||||||
|
"description": (
|
||||||
|
"ViSERA 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": (
|
||||||
|
"ViSERA 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": (
|
||||||
|
"ViSERA 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": (
|
||||||
|
"ViSERA 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": (
|
||||||
|
"ViSERA 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": (
|
||||||
|
"ViSERA'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 ViSERA license?",
|
||||||
|
"answer": (
|
||||||
|
"ViSERA 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 ViSERA in my research?",
|
||||||
|
"answer": (
|
||||||
|
"Please cite the following reference if you publish results obtained with "
|
||||||
|
"the help of ViSERA:\n\n"
|
||||||
|
"M. R. Salmanpour, I. Shiri, M. Hosseinzadeh, H. Zaidi, S. Ashrafinia, "
|
||||||
|
"M. Oveisi, A. Rahmim. ViSERA: 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 ViSERA support?",
|
||||||
|
"answer": (
|
||||||
|
"ViSERA 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 the Downloads 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 ViSERA suitable for clinical use?",
|
||||||
|
"answer": (
|
||||||
|
"ViSERA 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 at support@tecvico.com and through our "
|
||||||
|
"community Discord server. For bug reports and feature requests, please "
|
||||||
|
"use the Discord forum or contact us directly by email."
|
||||||
|
),
|
||||||
|
"order": 6,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
DOWNLOAD_ITEMS = [
|
||||||
|
{
|
||||||
|
"name": "ViSERA Desktop",
|
||||||
|
"platform": "windows",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"download_url": "https://github.com/tecvico/visera/releases/latest/download/ViSERA-Setup.exe",
|
||||||
|
"description": "Windows 10 and above (64-bit). Installer package.",
|
||||||
|
"is_active": True,
|
||||||
|
"order": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ViSERA Desktop",
|
||||||
|
"platform": "macos",
|
||||||
|
"version": "Coming Soon",
|
||||||
|
"download_url": "#",
|
||||||
|
"description": "macOS version is under development.",
|
||||||
|
"is_active": False,
|
||||||
|
"order": 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ViSERA 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 / ViSERA content from visera.ca"
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
self._seed_products()
|
||||||
|
self._seed_faq()
|
||||||
|
self._seed_downloads()
|
||||||
|
|
||||||
|
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_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}")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import DownloadItem, FAQEntry
|
||||||
|
|
||||||
|
|
||||||
|
@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")}),
|
||||||
|
("Settings", {"fields": ("order", "is_active")}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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,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,47 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class FAQEntry(models.Model):
|
||||||
|
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)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["order"]
|
||||||
|
verbose_name = "FAQ Entry"
|
||||||
|
verbose_name_plural = "FAQ Entries"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.question
|
||||||
|
|
||||||
|
|
||||||
|
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()})"
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.pages.models import DownloadItem, 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")
|
||||||
|
|
||||||
|
|
||||||
|
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 DownloadsViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
DownloadItem.objects.create(
|
||||||
|
name="ViSERA Desktop",
|
||||||
|
platform="windows",
|
||||||
|
version="1.0",
|
||||||
|
download_url="https://example.com/windows",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
DownloadItem.objects.create(
|
||||||
|
name="ViSERA Desktop",
|
||||||
|
platform="macos",
|
||||||
|
version="Coming Soon",
|
||||||
|
download_url="#",
|
||||||
|
is_active=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_downloads_returns_200(self):
|
||||||
|
response = self.client.get(reverse("pages:downloads"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_downloads_uses_correct_template(self):
|
||||||
|
response = self.client.get(reverse("pages:downloads"))
|
||||||
|
self.assertTemplateUsed(response, "pages/downloads.html")
|
||||||
|
|
||||||
|
def test_downloads_context_has_platform_keys(self):
|
||||||
|
response = self.client.get(reverse("pages:downloads"))
|
||||||
|
self.assertIn("windows_items", response.context)
|
||||||
|
self.assertIn("macos_items", response.context)
|
||||||
|
self.assertIn("linux_items", response.context)
|
||||||
|
|
||||||
|
def test_windows_item_in_context(self):
|
||||||
|
response = self.client.get(reverse("pages:downloads"))
|
||||||
|
self.assertEqual(response.context["windows_items"].count(), 1)
|
||||||
|
|
||||||
|
|
||||||
|
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("pages:downloads"),
|
||||||
|
]
|
||||||
|
for url in urls:
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertIn("nav_main_products", response.context, f"Missing nav_main_products 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("downloads/", views.DownloadsView.as_view(), name="downloads"),
|
||||||
|
path("faq/", views.FAQView.as_view(), name="faq"),
|
||||||
|
path("contact/", views.ContactView.as_view(), name="contact"),
|
||||||
|
]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from django.views.generic import ListView, TemplateView
|
||||||
|
|
||||||
|
from .models import DownloadItem, FAQEntry
|
||||||
|
|
||||||
|
|
||||||
|
class HomeView(TemplateView):
|
||||||
|
template_name = "pages/home.html"
|
||||||
|
|
||||||
|
|
||||||
|
class AboutView(TemplateView):
|
||||||
|
template_name = "pages/about.html"
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadsView(ListView):
|
||||||
|
template_name = "pages/downloads.html"
|
||||||
|
context_object_name = "download_items"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return DownloadItem.objects.filter(is_active=True).order_by("order", "platform")
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
all_items = DownloadItem.objects.order_by("order", "platform")
|
||||||
|
context["windows_items"] = all_items.filter(
|
||||||
|
platform=DownloadItem.PLATFORM_WINDOWS
|
||||||
|
)
|
||||||
|
context["macos_items"] = all_items.filter(platform=DownloadItem.PLATFORM_MACOS)
|
||||||
|
context["linux_items"] = all_items.filter(platform=DownloadItem.PLATFORM_LINUX)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class FAQView(ListView):
|
||||||
|
model = FAQEntry
|
||||||
|
template_name = "pages/faq.html"
|
||||||
|
context_object_name = "faq_entries"
|
||||||
|
queryset = FAQEntry.objects.filter(is_active=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactView(TemplateView):
|
||||||
|
template_name = "pages/contact.html"
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Article, ArticleSection, MainProduct, SubProduct
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleSectionInline(admin.TabularInline):
|
||||||
|
model = ArticleSection
|
||||||
|
extra = 1
|
||||||
|
fields = ("title", "value", "order")
|
||||||
|
ordering = ("order",)
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleInline(admin.StackedInline):
|
||||||
|
model = Article
|
||||||
|
extra = 0
|
||||||
|
fields = ("title", "description", "order")
|
||||||
|
ordering = ("order",)
|
||||||
|
show_change_link = True
|
||||||
|
|
||||||
|
|
||||||
|
class SubProductInline(admin.StackedInline):
|
||||||
|
model = SubProduct
|
||||||
|
extra = 0
|
||||||
|
fields = ("name", "slug", "short_description", "image", "order", "is_active")
|
||||||
|
ordering = ("order",)
|
||||||
|
show_change_link = True
|
||||||
|
prepopulated_fields = {"slug": ("name",)}
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(MainProduct)
|
||||||
|
class MainProductAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "order", "is_active", "created_at")
|
||||||
|
list_filter = ("is_active",)
|
||||||
|
search_fields = ("name", "description")
|
||||||
|
prepopulated_fields = {"slug": ("name",)}
|
||||||
|
list_editable = ("order", "is_active")
|
||||||
|
inlines = [SubProductInline]
|
||||||
|
fieldsets = (
|
||||||
|
(None, {"fields": ("name", "slug", "short_description", "description")}),
|
||||||
|
("Media", {"fields": ("image",)}),
|
||||||
|
("Settings", {"fields": ("order", "is_active")}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(SubProduct)
|
||||||
|
class SubProductAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "main_product", "order", "is_active", "created_at")
|
||||||
|
list_filter = ("is_active", "main_product")
|
||||||
|
search_fields = ("name", "description", "main_product__name")
|
||||||
|
prepopulated_fields = {"slug": ("name",)}
|
||||||
|
list_editable = ("order", "is_active")
|
||||||
|
raw_id_fields = ("main_product",)
|
||||||
|
inlines = [ArticleInline]
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
{"fields": ("main_product", "name", "slug", "short_description", "description")},
|
||||||
|
),
|
||||||
|
("Media", {"fields": ("image",)}),
|
||||||
|
("Settings", {"fields": ("order", "is_active")}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Article)
|
||||||
|
class ArticleAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("title", "sub_product", "order", "created_at")
|
||||||
|
list_filter = ("sub_product__main_product",)
|
||||||
|
search_fields = ("title", "description")
|
||||||
|
list_editable = ("order",)
|
||||||
|
raw_id_fields = ("sub_product",)
|
||||||
|
inlines = [ArticleSectionInline]
|
||||||
|
fieldsets = (
|
||||||
|
(None, {"fields": ("sub_product", "title", "description")}),
|
||||||
|
("Settings", {"fields": ("order",)}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ArticleSection)
|
||||||
|
class ArticleSectionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("title", "article", "order")
|
||||||
|
search_fields = ("title", "value", "article__title")
|
||||||
|
list_editable = ("order",)
|
||||||
|
raw_id_fields = ("article",)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ProductsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.products"
|
||||||
|
verbose_name = "Products"
|
||||||
@@ -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,111 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
image = models.ImageField(upload_to="products/main/", blank=True, null=True)
|
||||||
|
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", "name"]
|
||||||
|
verbose_name = "Main Product"
|
||||||
|
verbose_name_plural = "Main Products"
|
||||||
|
|
||||||
|
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})
|
||||||
|
|
||||||
|
|
||||||
|
class SubProduct(models.Model):
|
||||||
|
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()
|
||||||
|
image = models.ImageField(upload_to="products/sub/", blank=True, null=True)
|
||||||
|
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", "name"]
|
||||||
|
unique_together = [["main_product", "slug"]]
|
||||||
|
verbose_name = "Sub Product"
|
||||||
|
verbose_name_plural = "Sub Products"
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Article(models.Model):
|
||||||
|
sub_product = models.ForeignKey(
|
||||||
|
SubProduct,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="articles",
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["order", "title"]
|
||||||
|
verbose_name = "Article"
|
||||||
|
verbose_name_plural = "Articles"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleSection(models.Model):
|
||||||
|
article = models.ForeignKey(
|
||||||
|
Article,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="sections",
|
||||||
|
)
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
value = models.TextField()
|
||||||
|
order = models.PositiveIntegerField(default=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["order"]
|
||||||
|
verbose_name = "Article Section"
|
||||||
|
verbose_name_plural = "Article Sections"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.article.title} › {self.title}"
|
||||||
@@ -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,134 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct
|
||||||
|
|
||||||
|
|
||||||
|
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())
|
||||||
|
|
||||||
|
|
||||||
|
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,137 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.products.models import Article, ArticleSection, MainProduct, SubProduct
|
||||||
|
|
||||||
|
|
||||||
|
class ProductViewsSetup(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.main_product = MainProduct.objects.create(
|
||||||
|
name="ViSERA",
|
||||||
|
slug="visera",
|
||||||
|
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": "visera"})
|
||||||
|
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": "visera"})
|
||||||
|
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": "visera"})
|
||||||
|
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": "visera"})
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
|
class SubProductDetailViewTest(ProductViewsSetup):
|
||||||
|
def test_sub_detail_returns_200(self):
|
||||||
|
url = reverse(
|
||||||
|
"products:sub_product_detail",
|
||||||
|
kwargs={"main_slug": "visera", "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": "visera", "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": "visera", "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": "visera", "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": "visera", "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)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = "products"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", views.ProductOverviewView.as_view(), name="overview"),
|
||||||
|
path(
|
||||||
|
"<slug:main_slug>/",
|
||||||
|
views.MainProductDetailView.as_view(),
|
||||||
|
name="main_product_detail",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"<slug:main_slug>/<slug:sub_slug>/",
|
||||||
|
views.SubProductDetailView.as_view(),
|
||||||
|
name="sub_product_detail",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.views.generic import DetailView, ListView, TemplateView
|
||||||
|
|
||||||
|
from .models import MainProduct, SubProduct
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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").all()
|
||||||
|
context["siblings"] = (
|
||||||
|
SubProduct.objects.filter(
|
||||||
|
main_product=main_product,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
.exclude(pk=sub_product.pk)
|
||||||
|
.order_by("order", "name")
|
||||||
|
)
|
||||||
|
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,128 @@
|
|||||||
|
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.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"
|
||||||
|
|
||||||
|
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,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()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
environment:
|
||||||
|
DJANGO_SETTINGS_MODULE: config.settings.development
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- media_data:/app/media
|
||||||
|
command: python manage.py runserver 0.0.0.0:8000
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
DJANGO_SETTINGS_MODULE: config.settings.production
|
||||||
|
POSTGRES_HOST: db
|
||||||
|
volumes:
|
||||||
|
- media_data:/app/media
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
media_data:
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Waiting for PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}..."
|
||||||
|
until python -c "
|
||||||
|
import os, psycopg2, sys
|
||||||
|
try:
|
||||||
|
psycopg2.connect(
|
||||||
|
dbname=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', 'db'),
|
||||||
|
port=os.environ.get('POSTGRES_PORT', '5432'),
|
||||||
|
)
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception:
|
||||||
|
sys.exit(1)
|
||||||
|
"; do
|
||||||
|
echo "Database not ready. Retrying in 2 seconds..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "PostgreSQL is ready."
|
||||||
|
|
||||||
|
echo "Running database migrations..."
|
||||||
|
python manage.py migrate --noinput
|
||||||
|
|
||||||
|
echo "Starting application..."
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development")
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[pytest]
|
||||||
|
DJANGO_SETTINGS_MODULE = config.settings.development
|
||||||
|
python_files = test_*.py
|
||||||
|
python_classes = Test*
|
||||||
|
python_functions = test_*
|
||||||
|
addopts = -v --tb=short
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest>=8.3.0
|
||||||
|
pytest-django>=4.9.0
|
||||||
|
coverage>=7.6.0
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Django>=5.1,<6.0
|
||||||
|
psycopg2-binary>=2.9.9
|
||||||
|
gunicorn>=22.0.0
|
||||||
|
whitenoise[brotli]>=6.7.0
|
||||||
|
Pillow>=10.4.0
|
||||||
|
python-dotenv>=1.0.1
|
||||||
+1942
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const SELECTORS = {
|
||||||
|
navbar: '#navbar',
|
||||||
|
navbarToggle: '#navbarToggle',
|
||||||
|
navbarMenu: '#navbarMenu',
|
||||||
|
productsNavItem: '#productsNavItem',
|
||||||
|
faqItems: '.faq-item',
|
||||||
|
faqQuestion: '.faq-question',
|
||||||
|
faqAnswer: '.faq-answer',
|
||||||
|
fadeInElements: '.fade-in',
|
||||||
|
};
|
||||||
|
|
||||||
|
function initNavbarScroll() {
|
||||||
|
const navbar = document.querySelector(SELECTORS.navbar);
|
||||||
|
if (!navbar) return;
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
if (window.scrollY > 20) {
|
||||||
|
navbar.classList.add('scrolled');
|
||||||
|
} else {
|
||||||
|
navbar.classList.remove('scrolled');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
onScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initMobileMenu() {
|
||||||
|
const toggle = document.querySelector(SELECTORS.navbarToggle);
|
||||||
|
const menu = document.querySelector(SELECTORS.navbarMenu);
|
||||||
|
if (!toggle || !menu) return;
|
||||||
|
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
const isOpen = menu.classList.toggle('open');
|
||||||
|
toggle.classList.toggle('open', isOpen);
|
||||||
|
toggle.setAttribute('aria-expanded', String(isOpen));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && menu.classList.contains('open')) {
|
||||||
|
menu.classList.remove('open');
|
||||||
|
toggle.classList.remove('open');
|
||||||
|
toggle.setAttribute('aria-expanded', 'false');
|
||||||
|
toggle.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!menu.contains(e.target) && !toggle.contains(e.target)) {
|
||||||
|
menu.classList.remove('open');
|
||||||
|
toggle.classList.remove('open');
|
||||||
|
toggle.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initMegaMenu() {
|
||||||
|
const productsItem = document.querySelector(SELECTORS.productsNavItem);
|
||||||
|
if (!productsItem) return;
|
||||||
|
|
||||||
|
const trigger = productsItem.querySelector('.nav-link');
|
||||||
|
const megamenu = productsItem.querySelector('.megamenu');
|
||||||
|
if (!trigger || !megamenu) return;
|
||||||
|
|
||||||
|
productsItem.addEventListener('mouseenter', () => {
|
||||||
|
productsItem.classList.add('open');
|
||||||
|
trigger.setAttribute('aria-expanded', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
productsItem.addEventListener('mouseleave', () => {
|
||||||
|
productsItem.classList.remove('open');
|
||||||
|
trigger.setAttribute('aria-expanded', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
trigger.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
const isOpen = productsItem.classList.toggle('open');
|
||||||
|
trigger.setAttribute('aria-expanded', String(isOpen));
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
productsItem.classList.remove('open');
|
||||||
|
trigger.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initFAQAccordion() {
|
||||||
|
const faqItems = document.querySelectorAll(SELECTORS.faqItems);
|
||||||
|
faqItems.forEach((item) => {
|
||||||
|
const question = item.querySelector(SELECTORS.faqQuestion);
|
||||||
|
const answer = item.querySelector(SELECTORS.faqAnswer);
|
||||||
|
if (!question || !answer) return;
|
||||||
|
|
||||||
|
question.addEventListener('click', () => {
|
||||||
|
const isOpen = item.classList.contains('open');
|
||||||
|
|
||||||
|
faqItems.forEach((other) => {
|
||||||
|
if (other !== item) {
|
||||||
|
other.classList.remove('open');
|
||||||
|
const otherQuestion = other.querySelector(SELECTORS.faqQuestion);
|
||||||
|
const otherAnswer = other.querySelector(SELECTORS.faqAnswer);
|
||||||
|
if (otherQuestion) otherQuestion.setAttribute('aria-expanded', 'false');
|
||||||
|
if (otherAnswer) otherAnswer.hidden = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
item.classList.toggle('open', !isOpen);
|
||||||
|
question.setAttribute('aria-expanded', String(!isOpen));
|
||||||
|
answer.hidden = isOpen;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initIntersectionObserver() {
|
||||||
|
const elements = document.querySelectorAll(SELECTORS.fadeInElements);
|
||||||
|
if (!elements.length) return;
|
||||||
|
|
||||||
|
if (!('IntersectionObserver' in window)) {
|
||||||
|
elements.forEach((el) => el.classList.add('visible'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
entry.target.classList.add('visible');
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.12, rootMargin: '0px 0px -40px 0px' }
|
||||||
|
);
|
||||||
|
|
||||||
|
elements.forEach((el, index) => {
|
||||||
|
el.style.transitionDelay = `${index * 0.06}s`;
|
||||||
|
observer.observe(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
initNavbarScroll();
|
||||||
|
initMobileMenu();
|
||||||
|
initMegaMenu();
|
||||||
|
initFAQAccordion();
|
||||||
|
initIntersectionObserver();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{% load static %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="{% block meta_description %}Tecvico Corp — Advanced Medical Imaging & Radiomics Solutions{% endblock %}" />
|
||||||
|
<title>{% block title %}Tecvico Corp{% endblock %} | Tecvico</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="{% static 'css/main.css' %}" />
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{% include "partials/_navbar.html" %}
|
||||||
|
|
||||||
|
<main id="main-content">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{% include "partials/_footer.html" %}
|
||||||
|
|
||||||
|
<script src="{% static 'js/main.js' %}"></script>
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}What is Tecvico{% endblock %}
|
||||||
|
{% block meta_description %}Learn about Tecvico Corp and ViSERA — Visualized & Standardized Environment for Radiomics Analysis, developed at UBC and BC Cancer Research Institute.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<section class="page-hero" aria-labelledby="page-hero-heading">
|
||||||
|
<div class="page-hero-blobs" aria-hidden="true">
|
||||||
|
<div class="blob blob--1"></div>
|
||||||
|
<div class="blob blob--2"></div>
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<div class="page-hero-content">
|
||||||
|
<div class="section-badge">About</div>
|
||||||
|
<h1 class="page-hero-title" id="page-hero-heading">What is Tecvico?</h1>
|
||||||
|
<p class="page-hero-subtitle">
|
||||||
|
Visualized & Standardized Environment for Radiomics Analysis
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" aria-labelledby="about-intro-heading">
|
||||||
|
<div class="container">
|
||||||
|
<div class="about-intro glass-card fade-in">
|
||||||
|
<div class="about-intro-text">
|
||||||
|
<h2 id="about-intro-heading">Desktop Software for Medical Imaging Research</h2>
|
||||||
|
<ul class="about-list">
|
||||||
|
<li>Desktop software to improve usability, reusability, and reproducibility in medical imaging & healthcare research.</li>
|
||||||
|
<li>Development platform to create reproducible research workflows by connecting different tools.</li>
|
||||||
|
<li>Useful for collaborative research projects and for ensuring consistency across different studies.</li>
|
||||||
|
<li>User-friendly for different expertise levels, including radiation oncologists, radiologists, physicists & data scientists.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" aria-labelledby="standardization-heading">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-badge">Standards</div>
|
||||||
|
<h2 class="section-title" id="standardization-heading">Standardization</h2>
|
||||||
|
</div>
|
||||||
|
<div class="standards-grid">
|
||||||
|
<div class="standard-card glass-card fade-in">
|
||||||
|
<div class="standard-badge">IBSI 1.0</div>
|
||||||
|
<h3 class="standard-title">Radiomic Feature Extraction</h3>
|
||||||
|
<p class="standard-desc">
|
||||||
|
ViSERA is a python-based open-source package that enables standardized and
|
||||||
|
reproducible radiomic feature extraction in compliance with the Image Biomarker
|
||||||
|
Standardization Initiative (IBSI 1.0).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="standard-card glass-card fade-in">
|
||||||
|
<div class="standard-badge">IBSI 2.0</div>
|
||||||
|
<h3 class="standard-title">Image Filtering</h3>
|
||||||
|
<p class="standard-desc">
|
||||||
|
Image filters have been standardized against IBSI 2.0 by implementing and
|
||||||
|
validating several filter options, ensuring reproducibility across research
|
||||||
|
institutions worldwide.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="standard-card glass-card fade-in">
|
||||||
|
<div class="standard-badge">Python</div>
|
||||||
|
<h3 class="standard-title">Open Source</h3>
|
||||||
|
<p class="standard-desc">
|
||||||
|
ViSERA is a major, entirely-revamped upgrade to the original SERA (Matlab-based),
|
||||||
|
now built on Python for broader accessibility and community contribution.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="standard-card glass-card fade-in">
|
||||||
|
<div class="standard-badge">End-to-End</div>
|
||||||
|
<h3 class="standard-title">Standardized Workflows</h3>
|
||||||
|
<p class="standard-desc">
|
||||||
|
ViSERA employs a number of popular image processing algorithms to create
|
||||||
|
end-to-end standardized workflows for consistent, reproducible research results.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" aria-labelledby="history-heading">
|
||||||
|
<div class="container">
|
||||||
|
<div class="history-block glass-card fade-in">
|
||||||
|
<div class="history-content">
|
||||||
|
<div class="section-badge">History</div>
|
||||||
|
<h2 id="history-heading">Our Origins</h2>
|
||||||
|
<p>
|
||||||
|
Tecvico (formerly known as Visera) 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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Our mission is to bridge the gap between cutting-edge radiomics research and
|
||||||
|
practical clinical application by providing standardized, reproducible, and
|
||||||
|
user-friendly software tools.
|
||||||
|
</p>
|
||||||
|
<div class="history-links">
|
||||||
|
<a href="https://www.qurit.ca" class="btn-ghost" target="_blank" rel="noopener noreferrer">Qurit Lab</a>
|
||||||
|
<a href="https://www.ubc.ca" class="btn-ghost" target="_blank" rel="noopener noreferrer">UBC</a>
|
||||||
|
<a href="https://www.bccrc.ca" class="btn-ghost" target="_blank" rel="noopener noreferrer">BC Cancer</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="history-visual" aria-hidden="true">
|
||||||
|
<div class="history-blob history-blob--1"></div>
|
||||||
|
<div class="history-blob history-blob--2"></div>
|
||||||
|
<div class="history-year">2021</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Contact{% endblock %}
|
||||||
|
{% block meta_description %}Contact Tecvico Corp — support for ViSERA software and general inquiries.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<section class="page-hero" aria-labelledby="contact-heading">
|
||||||
|
<div class="page-hero-blobs" aria-hidden="true">
|
||||||
|
<div class="blob blob--1"></div>
|
||||||
|
<div class="blob blob--2"></div>
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<div class="page-hero-content">
|
||||||
|
<div class="section-badge">Get in Touch</div>
|
||||||
|
<h1 class="page-hero-title" id="contact-heading">Contact Us</h1>
|
||||||
|
<p class="page-hero-subtitle">Support for ViSERA software is available via email and Discord.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" aria-labelledby="contact-details-heading">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="sr-only" id="contact-details-heading">Contact Details</h2>
|
||||||
|
<div class="contact-grid">
|
||||||
|
|
||||||
|
<div class="contact-card glass-card fade-in">
|
||||||
|
<div class="contact-icon" aria-hidden="true">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||||
|
<path d="M5 8C5 6.895 5.895 6 7 6H25C26.105 6 27 6.895 27 8V22C27 23.105 26.105 24 25 24H7C5.895 24 5 23.105 5 22V8Z" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M5 9L16 16L27 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="contact-card-title">Email Support</h3>
|
||||||
|
<p class="contact-card-desc">
|
||||||
|
For software support and general inquiries, reach us at:
|
||||||
|
</p>
|
||||||
|
<a href="mailto:support@tecvico.com" class="contact-email-link">
|
||||||
|
support@tecvico.com
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-card glass-card fade-in">
|
||||||
|
<div class="contact-icon" aria-hidden="true">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.08.114 18.1.133 18.11a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="contact-card-title">Discord Community</h3>
|
||||||
|
<p class="contact-card-desc">
|
||||||
|
Join our Discord server for community support, feature discussions, and announcements.
|
||||||
|
</p>
|
||||||
|
<a href="https://discord.gg/9XxA6pV9hb" class="btn-primary" target="_blank" rel="noopener noreferrer">
|
||||||
|
Join Discord Forum
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="contact-card glass-card fade-in">
|
||||||
|
<div class="contact-icon" aria-hidden="true">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||||
|
<path d="M16 3C11.029 3 7 7.029 7 12C7 19 16 29 16 29C16 29 25 19 25 12C25 7.029 20.971 3 16 3Z" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<circle cx="16" cy="12" r="3" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="contact-card-title">Office Address</h3>
|
||||||
|
<address class="contact-address">
|
||||||
|
<p>BC Cancer Research Center</p>
|
||||||
|
<p>675 West 10th Ave</p>
|
||||||
|
<p>Office 6-112</p>
|
||||||
|
<p>Vancouver, BC, V5Z 1L3</p>
|
||||||
|
<p>Canada</p>
|
||||||
|
</address>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Downloads{% endblock %}
|
||||||
|
{% block meta_description %}Download ViSERA by Tecvico — available for Windows, macOS, and Linux.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<section class="page-hero" aria-labelledby="downloads-heading">
|
||||||
|
<div class="page-hero-blobs" aria-hidden="true">
|
||||||
|
<div class="blob blob--1"></div>
|
||||||
|
<div class="blob blob--2"></div>
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<div class="page-hero-content">
|
||||||
|
<div class="section-badge">Software</div>
|
||||||
|
<h1 class="page-hero-title" id="downloads-heading">Downloads</h1>
|
||||||
|
<p class="page-hero-subtitle">Download the latest version of ViSERA for your platform.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" aria-labelledby="download-platforms-heading">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="sr-only" id="download-platforms-heading">Available Platforms</h2>
|
||||||
|
<div class="downloads-grid">
|
||||||
|
|
||||||
|
<div class="download-card glass-card fade-in {% if not windows_items %}download-card--unavailable{% endif %}">
|
||||||
|
<div class="download-platform-icon" aria-hidden="true">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.4H10.949M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-12.9-1.801"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="download-platform-name">Windows</h3>
|
||||||
|
<p class="download-platform-desc">Windows 10 and up (64-bit)</p>
|
||||||
|
{% if windows_items %}
|
||||||
|
{% for item in windows_items %}
|
||||||
|
<div class="download-item">
|
||||||
|
<span class="download-version">v{{ item.version }}</span>
|
||||||
|
{% if item.description %}<p class="download-item-desc">{{ item.description }}</p>{% endif %}
|
||||||
|
{% if item.download_url and item.download_url != '#' %}
|
||||||
|
<a href="{{ item.download_url }}" class="btn-primary download-btn" download>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
|
<path d="M9 2V12M9 12L5 8M9 12L13 8M3 15H15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<span class="coming-soon-badge">Coming Soon</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="download-card glass-card fade-in {% if not macos_items %}download-card--unavailable{% endif %}">
|
||||||
|
<div class="download-platform-icon" aria-hidden="true">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="download-platform-name">macOS</h3>
|
||||||
|
<p class="download-platform-desc">macOS 11 Big Sur and up</p>
|
||||||
|
{% if macos_items %}
|
||||||
|
{% for item in macos_items %}
|
||||||
|
<div class="download-item">
|
||||||
|
<span class="download-version">v{{ item.version }}</span>
|
||||||
|
{% if item.description %}<p class="download-item-desc">{{ item.description }}</p>{% endif %}
|
||||||
|
{% if item.download_url and item.download_url != '#' %}
|
||||||
|
<a href="{{ item.download_url }}" class="btn-primary download-btn" download>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
|
<path d="M9 2V12M9 12L5 8M9 12L13 8M3 15H15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<span class="coming-soon-badge">Coming Soon</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="download-card glass-card fade-in {% if not linux_items %}download-card--unavailable{% endif %}">
|
||||||
|
<div class="download-platform-icon" aria-hidden="true">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.268-.45.6-.663.839-.199.199-.485.267-.797.4-.313.136-.658.269-.864.68-.09.189-.136.394-.132.602 0 .199.027.4.055.536.058.399.116.728.04.97-.249.68-.28 1.145-.106 1.484.174.334.535.47.94.601.81.2 1.91.135 2.774.6.926.466 1.866.67 2.616.47.526-.116.97-.464 1.208-.946.587-.003 1.23-.EraseINIT.3.337.572.95.23 1.681-.078.277-.21.557-.303.815-.125.35-.169.63-.046.875.315.648 1.086.698 1.728.hyperparameter698.256 0 .504-.034.72-.046.372-.022.626.026.783.17.428.39.405 1.353.504 2.408.096.99.184 2.023.618 2.813.22.391.528.717.938.939.41.22.915.316 1.44.282.52-.033 1.092-.218 1.544-.668.296-.296.522-.723.58-1.226.029-.248.017-.51-.02-.77-.15-1.168.056-2.22.617-2.82.274-.294.548-.452.748-.606.238-.186.427-.41.554-.676.247-.517.194-1.107-.026-1.633-.11-.271-.238-.517-.324-.773-.148-.448-.172-.913.053-1.377.226-.467.67-.876 1.02-1.428a.424.424 0 00.11-.134c.25-.43.31-.915.245-1.395-.065-.476-.245-.937-.49-1.37-.245-.436-.54-.838-.807-1.278-.254-.428-.47-.903-.522-1.445-.1-.994.268-2.1.368-3.158.05-.53.04-1.046-.059-1.518-.097-.47-.3-.9-.616-1.24-.636-.68-1.608-.904-2.527-.974-.45-.035-.899-.026-1.334.026zm.042 1.174c.42-.047.852-.056 1.277-.022.812.062 1.59.262 2.047.758.228.245.37.554.44.918.073.366.08.78.03 1.233-.103 1.055-.486 2.198-.36 3.34.061.567.302 1.09.575 1.56.27.46.572.876.822 1.319.25.443.43.904.483 1.362.053.45.003.895-.2 1.269a.37.37 0 00-.099.11c-.35.56-.83.993-1.1 1.57-.28.587-.295 1.218-.12 1.822.088.3.215.585.33.869.217.526.253 1.015.066 1.4a1.088 1.088 0 01-.354.43c-.22.162-.502.322-.806.638-.7.742-.924 1.963-.756 3.254.028.207.038.414.013.605-.045.38-.19.688-.41.908-.33.335-.765.497-1.178.524-.414.027-.83-.054-1.14-.22-.315-.169-.54-.43-.691-.714-.358-.647-.445-1.572-.54-2.553-.1-1.012-.118-2.066-.67-2.593-.269-.247-.645-.345-1.117-.32-.232.014-.455.047-.677.047-.44 0-.836-.054-.97-.33-.065-.133-.073-.36.043-.688.1-.29.238-.586.292-.866.133-.676.05-1.313-.313-1.75-.364-.44-1.027-.604-1.718-.777-.69-.176-1.44-.396-1.582-.862-.092-.302-.06-.666.11-1.127.087-.23.097-.482.054-.727-.083-.499.053-.986.22-1.427.246-.66 1.463-2.382 2.39-3.479.87-1.044 1.15-2.02 1.22-3.167.063-1.26-.744-5.02 2.57-5.27z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="download-platform-name">Linux</h3>
|
||||||
|
<p class="download-platform-desc">Ubuntu 20.04+ and compatible distributions</p>
|
||||||
|
{% if linux_items %}
|
||||||
|
{% for item in linux_items %}
|
||||||
|
<div class="download-item">
|
||||||
|
<span class="download-version">v{{ item.version }}</span>
|
||||||
|
{% if item.description %}<p class="download-item-desc">{{ item.description }}</p>{% endif %}
|
||||||
|
{% if item.download_url and item.download_url != '#' %}
|
||||||
|
<a href="{{ item.download_url }}" class="btn-primary download-btn" download>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
|
<path d="M9 2V12M9 12L5 8M9 12L13 8M3 15H15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<span class="coming-soon-badge">Coming Soon</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" aria-labelledby="reinstall-heading">
|
||||||
|
<div class="container">
|
||||||
|
<div class="info-block glass-card fade-in">
|
||||||
|
<h2 id="reinstall-heading">Installation Notes</h2>
|
||||||
|
<p>
|
||||||
|
If you have installed an older version of ViSERA, you can install the new version
|
||||||
|
over it without removing the previous installation. However, if you encounter any
|
||||||
|
problems, please remove the old version first before reinstalling.
|
||||||
|
</p>
|
||||||
|
<a href="{% url 'pages:faq' %}" class="btn-ghost">View Full FAQ</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}FAQ{% endblock %}
|
||||||
|
{% block meta_description %}Frequently asked questions about ViSERA by Tecvico — licensing, citation, system requirements, and more.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<section class="page-hero" aria-labelledby="faq-heading">
|
||||||
|
<div class="page-hero-blobs" aria-hidden="true">
|
||||||
|
<div class="blob blob--1"></div>
|
||||||
|
<div class="blob blob--2"></div>
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<div class="page-hero-content">
|
||||||
|
<div class="section-badge">Help</div>
|
||||||
|
<h1 class="page-hero-title" id="faq-heading">FAQ</h1>
|
||||||
|
<p class="page-hero-subtitle">Frequently Asked Questions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" aria-labelledby="faq-list-heading">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="sr-only" id="faq-list-heading">FAQ List</h2>
|
||||||
|
{% if faq_entries %}
|
||||||
|
<div class="faq-list">
|
||||||
|
{% for entry in faq_entries %}
|
||||||
|
<div class="faq-item glass-card fade-in">
|
||||||
|
<button
|
||||||
|
class="faq-question"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="faq-answer-{{ entry.pk }}"
|
||||||
|
id="faq-question-{{ entry.pk }}"
|
||||||
|
>
|
||||||
|
{{ entry.question }}
|
||||||
|
<svg class="faq-icon" width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
|
<path d="M5 8L10 13L15 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="faq-answer"
|
||||||
|
id="faq-answer-{{ entry.pk }}"
|
||||||
|
role="region"
|
||||||
|
aria-labelledby="faq-question-{{ entry.pk }}"
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<p>{{ entry.answer }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-state">No FAQ entries available yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="faq-contact glass-card fade-in">
|
||||||
|
<h2>Still Have Questions?</h2>
|
||||||
|
<p>Can't find the answer you're looking for? Get in touch with our support team.</p>
|
||||||
|
<a href="{% url 'pages:contact' %}" class="btn-primary">Contact Support</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Home{% endblock %}
|
||||||
|
{% block meta_description %}Tecvico Corp — A Powerful Workflow Generator for Standardized Radiomics Analysis and Medical Image Visualization.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<section class="hero" aria-labelledby="hero-heading">
|
||||||
|
<div class="hero-blobs" aria-hidden="true">
|
||||||
|
<div class="blob blob--1"></div>
|
||||||
|
<div class="blob blob--2"></div>
|
||||||
|
<div class="blob blob--3"></div>
|
||||||
|
</div>
|
||||||
|
<div class="container hero-content">
|
||||||
|
<div class="hero-badge">Developing since 2021</div>
|
||||||
|
<h1 class="hero-title" id="hero-heading">
|
||||||
|
Tecvico,
|
||||||
|
<br />
|
||||||
|
<span class="gradient-text">A Powerful Workflow Generator</span>
|
||||||
|
</h1>
|
||||||
|
<p class="hero-subtitle">
|
||||||
|
for Standardized Radiomics Analysis and Medical Image Visualization
|
||||||
|
</p>
|
||||||
|
<p class="hero-description">
|
||||||
|
ViSERA 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.
|
||||||
|
</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<a href="{% url 'pages:downloads' %}" class="btn-primary">Download Now</a>
|
||||||
|
<a href="{% url 'pages:about' %}" class="btn-ghost">About Tecvico</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section features-section" aria-labelledby="features-heading">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-badge">Capabilities</div>
|
||||||
|
<h2 class="section-title" id="features-heading">Important Features</h2>
|
||||||
|
<p class="section-desc">
|
||||||
|
Comprehensive tools for medical imaging research, standardized and reproducible.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="features-grid">
|
||||||
|
{% for feature in features %}
|
||||||
|
<div class="feature-card glass-card fade-in">
|
||||||
|
<div class="feature-icon" aria-hidden="true">{{ feature.icon }}</div>
|
||||||
|
<h3 class="feature-title">{{ feature.title }}</h3>
|
||||||
|
<p class="feature-desc">{{ feature.desc }}</p>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="feature-card glass-card fade-in">
|
||||||
|
<div class="feature-icon" aria-hidden="true">⚗️</div>
|
||||||
|
<h3 class="feature-title">Image Filtering</h3>
|
||||||
|
<p class="feature-desc">Standardized image filtering techniques compliant with IBSI 2.0 guidelines.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card glass-card fade-in">
|
||||||
|
<div class="feature-icon" aria-hidden="true">🖥️</div>
|
||||||
|
<h3 class="feature-title">Professional Viewer</h3>
|
||||||
|
<p class="feature-desc">Comfortable, professional medical image viewer with multi-modality support.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card glass-card fade-in">
|
||||||
|
<div class="feature-icon" aria-hidden="true">📊</div>
|
||||||
|
<h3 class="feature-title">Radiomics Features</h3>
|
||||||
|
<p class="feature-desc">Handcrafted radiomics feature generation standardized by IBSI 1.0.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card glass-card fade-in">
|
||||||
|
<div class="feature-icon" aria-hidden="true">🔄</div>
|
||||||
|
<h3 class="feature-title">Format Support</h3>
|
||||||
|
<p class="feature-desc">NIFTI, DICOM, NRRD, and more — comprehensive multi-format support.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card glass-card fade-in">
|
||||||
|
<div class="feature-icon" aria-hidden="true">🗂️</div>
|
||||||
|
<h3 class="feature-title">Image Registration</h3>
|
||||||
|
<p class="feature-desc">Advanced image registration, fusion, and standardized SUV conversion.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card glass-card fade-in">
|
||||||
|
<div class="feature-icon" aria-hidden="true">🔬</div>
|
||||||
|
<h3 class="feature-title">RT Struct Support</h3>
|
||||||
|
<p class="feature-desc">Full RT struct support for radiation oncology workflows and research.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if nav_main_products %}
|
||||||
|
<section class="section products-section" aria-labelledby="products-heading">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-badge">Our Software</div>
|
||||||
|
<h2 class="section-title" id="products-heading">Products</h2>
|
||||||
|
<p class="section-desc">Explore our suite of medical imaging and radiomics tools.</p>
|
||||||
|
</div>
|
||||||
|
<div class="products-grid">
|
||||||
|
{% for product in nav_main_products %}
|
||||||
|
<a href="{{ product.get_absolute_url }}" class="product-card glass-card fade-in">
|
||||||
|
{% if product.image %}
|
||||||
|
<div class="product-card-image">
|
||||||
|
<img src="{{ product.image.url }}" alt="{{ product.name }}" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="product-card-body">
|
||||||
|
<h3 class="product-card-title">{{ product.name }}</h3>
|
||||||
|
<p class="product-card-desc">{{ product.short_description }}</p>
|
||||||
|
<span class="product-card-link">
|
||||||
|
Explore
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path d="M3 8H13M13 8L9 4M13 8L9 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="section problems-section" aria-labelledby="problems-heading">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-badge">Value Proposition</div>
|
||||||
|
<h2 class="section-title" id="problems-heading">What Problems Does Tecvico Solve?</h2>
|
||||||
|
</div>
|
||||||
|
<div class="problems-grid">
|
||||||
|
<div class="problem-item glass-card fade-in">
|
||||||
|
<div class="problem-number" aria-hidden="true">01</div>
|
||||||
|
<h3 class="problem-title">Accessibility</h3>
|
||||||
|
<p class="problem-desc">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="problem-item glass-card fade-in">
|
||||||
|
<div class="problem-number" aria-hidden="true">02</div>
|
||||||
|
<h3 class="problem-title">Integrated Tools</h3>
|
||||||
|
<p class="problem-desc">
|
||||||
|
Tecvico integrates a vast collection of tools and resources from various domains
|
||||||
|
of healthcare and medical imaging research in a common, unified environment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="problem-item glass-card fade-in">
|
||||||
|
<div class="problem-number" aria-hidden="true">03</div>
|
||||||
|
<h3 class="problem-title">Flexibility</h3>
|
||||||
|
<p class="problem-desc">
|
||||||
|
Tecvico offers flexibility in terms of tool optimization and workflow customization
|
||||||
|
to match your specific research requirements.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="problem-item glass-card fade-in">
|
||||||
|
<div class="problem-number" aria-hidden="true">04</div>
|
||||||
|
<h3 class="problem-title">Reproducibility</h3>
|
||||||
|
<p class="problem-desc">
|
||||||
|
Improve usability, reusability, and reproducibility (URR) through a workflow
|
||||||
|
management system that allows researchers to easily create, share, and reuse
|
||||||
|
analysis pipelines.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section about-strip" aria-labelledby="about-strip-heading">
|
||||||
|
<div class="container">
|
||||||
|
<div class="about-strip-inner glass-card">
|
||||||
|
<div class="about-strip-content">
|
||||||
|
<div class="section-badge">Our Story</div>
|
||||||
|
<h2 class="section-title" id="about-strip-heading">More to Know</h2>
|
||||||
|
<p class="about-strip-text">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<a href="{% url 'pages:about' %}" class="btn-primary">Learn More</a>
|
||||||
|
</div>
|
||||||
|
<div class="about-strip-blobs" aria-hidden="true">
|
||||||
|
<div class="strip-blob strip-blob--1"></div>
|
||||||
|
<div class="strip-blob strip-blob--2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
{% load static %}
|
||||||
|
<footer class="footer" role="contentinfo">
|
||||||
|
<div class="footer-blobs" aria-hidden="true">
|
||||||
|
<div class="footer-blob footer-blob--1"></div>
|
||||||
|
<div class="footer-blob footer-blob--2"></div>
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<div class="footer-grid">
|
||||||
|
|
||||||
|
<div class="footer-brand-col">
|
||||||
|
<a href="{% url 'pages:home' %}" class="footer-logo" aria-label="Tecvico home">
|
||||||
|
<span class="brand-icon">T</span>
|
||||||
|
<span class="brand-name">Tecvico</span>
|
||||||
|
</a>
|
||||||
|
<p class="footer-tagline">
|
||||||
|
Advancing medical imaging and radiomics research through innovative, standardized software solutions.
|
||||||
|
</p>
|
||||||
|
<a href="https://discord.gg/9XxA6pV9hb" class="btn-ghost btn-sm" target="_blank" rel="noopener noreferrer">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.08.114 18.1.133 18.11a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/>
|
||||||
|
</svg>
|
||||||
|
Join Discord
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-links-col">
|
||||||
|
<h3 class="footer-heading">Navigation</h3>
|
||||||
|
<ul class="footer-links" role="list">
|
||||||
|
<li><a href="{% url 'pages:home' %}">Home</a></li>
|
||||||
|
<li><a href="{% url 'pages:about' %}">What is Tecvico</a></li>
|
||||||
|
<li><a href="{% url 'products:overview' %}">Products</a></li>
|
||||||
|
<li><a href="{% url 'pages:downloads' %}">Downloads</a></li>
|
||||||
|
<li><a href="{% url 'pages:faq' %}">FAQ</a></li>
|
||||||
|
<li><a href="{% url 'pages:contact' %}">Contact</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if nav_main_products %}
|
||||||
|
<div class="footer-links-col">
|
||||||
|
<h3 class="footer-heading">Products</h3>
|
||||||
|
<ul class="footer-links" role="list">
|
||||||
|
{% for product in nav_main_products %}
|
||||||
|
<li><a href="{{ product.get_absolute_url }}">{{ product.name }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="footer-contact-col">
|
||||||
|
<h3 class="footer-heading">Contact</h3>
|
||||||
|
<address class="footer-address">
|
||||||
|
<p>BC Cancer Research Center</p>
|
||||||
|
<p>675 West 10th Ave, Office 6-112</p>
|
||||||
|
<p>Vancouver, BC, V5Z 1L3</p>
|
||||||
|
</address>
|
||||||
|
<a href="mailto:support@tecvico.com" class="footer-email">support@tecvico.com</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-bottom">
|
||||||
|
<p class="footer-copy">
|
||||||
|
© {% now "Y" %} Tecvico Corp. All rights reserved. Formerly known as Visera.
|
||||||
|
</p>
|
||||||
|
<p class="footer-credit">
|
||||||
|
Developed at <a href="https://www.qurit.ca" target="_blank" rel="noopener noreferrer">Qurit Lab</a>,
|
||||||
|
<a href="https://www.ubc.ca" target="_blank" rel="noopener noreferrer">University of British Columbia</a>
|
||||||
|
& <a href="https://www.bccrc.ca" target="_blank" rel="noopener noreferrer">BC Cancer Research Institute</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
{% load static %}
|
||||||
|
<nav class="navbar" id="navbar" role="navigation" aria-label="Main navigation">
|
||||||
|
<div class="navbar-container">
|
||||||
|
|
||||||
|
<a href="{% url 'pages:home' %}" class="navbar-brand" aria-label="Tecvico home">
|
||||||
|
<span class="brand-icon">T</span>
|
||||||
|
<span class="brand-name">Tecvico</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button class="navbar-toggle" id="navbarToggle" aria-expanded="false" aria-controls="navbarMenu" aria-label="Toggle navigation">
|
||||||
|
<span class="toggle-bar"></span>
|
||||||
|
<span class="toggle-bar"></span>
|
||||||
|
<span class="toggle-bar"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="navbar-menu" id="navbarMenu">
|
||||||
|
<ul class="nav-list" role="list">
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="{% url 'pages:home' %}" class="nav-link {% if request.resolver_match.url_name == 'home' %}active{% endif %}">
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% if nav_main_products %}
|
||||||
|
<li class="nav-item has-megamenu" id="productsNavItem">
|
||||||
|
<a href="{% url 'products:overview' %}" class="nav-link {% if 'products' in request.resolver_match.namespace %}active{% endif %}" aria-haspopup="true" aria-expanded="false" id="productsTrigger">
|
||||||
|
Products
|
||||||
|
<svg class="nav-arrow" width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
|
<path d="M2 4L6 8L10 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<div class="megamenu" role="menu" aria-labelledby="productsTrigger">
|
||||||
|
<div class="megamenu-inner">
|
||||||
|
<div class="megamenu-grid">
|
||||||
|
{% for product in nav_main_products %}
|
||||||
|
<div class="megamenu-column">
|
||||||
|
<a href="{{ product.get_absolute_url }}" class="megamenu-product-title" role="menuitem">
|
||||||
|
{{ product.name }}
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
|
<path d="M3 7H11M11 7L8 4M11 7L8 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<p class="megamenu-product-desc">{{ product.short_description }}</p>
|
||||||
|
{% with subs=product.sub_products.all %}
|
||||||
|
{% if subs %}
|
||||||
|
<ul class="megamenu-sublist" role="list">
|
||||||
|
{% for sub in subs %}
|
||||||
|
{% if sub.is_active %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ sub.get_absolute_url }}" class="megamenu-sublink" role="menuitem">
|
||||||
|
<span class="sublink-dot"></span>
|
||||||
|
{{ sub.name }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="{% url 'pages:about' %}" class="nav-link {% if request.resolver_match.url_name == 'about' %}active{% endif %}">
|
||||||
|
What is Tecvico
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="{% url 'pages:downloads' %}" class="nav-link {% if request.resolver_match.url_name == 'downloads' %}active{% endif %}">
|
||||||
|
Downloads
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="{% url 'pages:faq' %}" class="nav-link {% if request.resolver_match.url_name == 'faq' %}active{% endif %}">
|
||||||
|
FAQ
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="{% url 'pages:contact' %}" class="nav-link nav-link--cta {% if request.resolver_match.url_name == 'contact' %}active{% endif %}">
|
||||||
|
Contact
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}{{ main_product.name }}{% endblock %}
|
||||||
|
{% block meta_description %}{{ main_product.short_description }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<section class="page-hero" aria-labelledby="main-product-heading">
|
||||||
|
<div class="page-hero-blobs" aria-hidden="true">
|
||||||
|
<div class="blob blob--1"></div>
|
||||||
|
<div class="blob blob--2"></div>
|
||||||
|
<div class="blob blob--3"></div>
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||||
|
<ol class="breadcrumb-list" role="list">
|
||||||
|
<li><a href="{% url 'pages:home' %}">Home</a></li>
|
||||||
|
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
||||||
|
<li><a href="{% url 'products:overview' %}">Products</a></li>
|
||||||
|
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
||||||
|
<li aria-current="page">{{ main_product.name }}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<div class="page-hero-content">
|
||||||
|
<div class="section-badge">Product</div>
|
||||||
|
<h1 class="page-hero-title" id="main-product-heading">{{ main_product.name }}</h1>
|
||||||
|
<p class="page-hero-subtitle">{{ main_product.short_description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" aria-labelledby="product-desc-heading">
|
||||||
|
<div class="container">
|
||||||
|
<div class="product-detail-intro glass-card fade-in">
|
||||||
|
<h2 class="sr-only" id="product-desc-heading">About {{ main_product.name }}</h2>
|
||||||
|
{% if main_product.image %}
|
||||||
|
<div class="product-detail-image">
|
||||||
|
<img src="{{ main_product.image.url }}" alt="{{ main_product.name }}" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="product-detail-text">
|
||||||
|
<p class="product-detail-desc">{{ main_product.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% with subs=main_product.sub_products.all %}
|
||||||
|
{% if subs %}
|
||||||
|
<section class="section" aria-labelledby="subproducts-heading">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-badge">Modules</div>
|
||||||
|
<h2 class="section-title" id="subproducts-heading">{{ main_product.name }} Modules</h2>
|
||||||
|
<p class="section-desc">
|
||||||
|
Explore the different modules and capabilities within {{ main_product.name }}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="subproducts-grid">
|
||||||
|
{% for sub in subs %}
|
||||||
|
{% if sub.is_active %}
|
||||||
|
<a href="{{ sub.get_absolute_url }}" class="subproduct-card glass-card fade-in">
|
||||||
|
{% if sub.image %}
|
||||||
|
<div class="subproduct-card-image">
|
||||||
|
<img src="{{ sub.image.url }}" alt="{{ sub.name }}" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="subproduct-card-body">
|
||||||
|
<h3 class="subproduct-card-title">{{ sub.name }}</h3>
|
||||||
|
<p class="subproduct-card-desc">{{ sub.short_description }}</p>
|
||||||
|
<span class="subproduct-card-link">
|
||||||
|
Learn More
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||||
|
<path d="M3 7H11M11 7L8 4M11 7L8 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Products{% endblock %}
|
||||||
|
{% block meta_description %}Explore Tecvico's suite of medical imaging and radiomics software products.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<section class="page-hero" aria-labelledby="products-overview-heading">
|
||||||
|
<div class="page-hero-blobs" aria-hidden="true">
|
||||||
|
<div class="blob blob--1"></div>
|
||||||
|
<div class="blob blob--2"></div>
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<div class="page-hero-content">
|
||||||
|
<div class="section-badge">Software Suite</div>
|
||||||
|
<h1 class="page-hero-title" id="products-overview-heading">Our Products</h1>
|
||||||
|
<p class="page-hero-subtitle">Advanced tools for medical imaging research and radiomics analysis.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" aria-labelledby="products-list-heading">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="sr-only" id="products-list-heading">Product List</h2>
|
||||||
|
{% if main_products %}
|
||||||
|
<div class="products-overview-grid">
|
||||||
|
{% for product in main_products %}
|
||||||
|
<div class="product-overview-card glass-card fade-in">
|
||||||
|
{% if product.image %}
|
||||||
|
<div class="product-overview-image">
|
||||||
|
<img src="{{ product.image.url }}" alt="{{ product.name }}" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="product-overview-body">
|
||||||
|
<h2 class="product-overview-title">{{ product.name }}</h2>
|
||||||
|
<p class="product-overview-short">{{ product.short_description }}</p>
|
||||||
|
<p class="product-overview-desc">{{ product.description }}</p>
|
||||||
|
<a href="{{ product.get_absolute_url }}" class="btn-primary">Explore {{ product.name }}</a>
|
||||||
|
</div>
|
||||||
|
{% with subs=product.sub_products.all %}
|
||||||
|
{% if subs %}
|
||||||
|
<div class="product-overview-subs">
|
||||||
|
<h3 class="product-overview-subs-title">Modules</h3>
|
||||||
|
<ul class="product-overview-subs-list" role="list">
|
||||||
|
{% for sub in subs %}
|
||||||
|
{% if sub.is_active %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ sub.get_absolute_url }}" class="sub-link-chip">
|
||||||
|
{{ sub.name }}
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
|
<path d="M2.5 6H9.5M9.5 6L7 3.5M9.5 6L7 8.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state-block glass-card">
|
||||||
|
<p>No products available at this time. Check back soon.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}{{ sub_product.name }} — {{ main_product.name }}{% endblock %}
|
||||||
|
{% block meta_description %}{{ sub_product.short_description }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<section class="page-hero" aria-labelledby="sub-product-heading">
|
||||||
|
<div class="page-hero-blobs" aria-hidden="true">
|
||||||
|
<div class="blob blob--1"></div>
|
||||||
|
<div class="blob blob--2"></div>
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||||
|
<ol class="breadcrumb-list" role="list">
|
||||||
|
<li><a href="{% url 'pages:home' %}">Home</a></li>
|
||||||
|
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
||||||
|
<li><a href="{% url 'products:overview' %}">Products</a></li>
|
||||||
|
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
||||||
|
<li><a href="{{ main_product.get_absolute_url }}">{{ main_product.name }}</a></li>
|
||||||
|
<li aria-hidden="true" class="breadcrumb-sep">›</li>
|
||||||
|
<li aria-current="page">{{ sub_product.name }}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<div class="page-hero-content">
|
||||||
|
<div class="section-badge">{{ main_product.name }}</div>
|
||||||
|
<h1 class="page-hero-title" id="sub-product-heading">{{ sub_product.name }}</h1>
|
||||||
|
<p class="page-hero-subtitle">{{ sub_product.short_description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="section sub-product-layout">
|
||||||
|
<div class="container">
|
||||||
|
<div class="sub-product-grid">
|
||||||
|
|
||||||
|
<main class="sub-product-main">
|
||||||
|
{% if sub_product.image %}
|
||||||
|
<div class="sub-product-image glass-card fade-in">
|
||||||
|
<img src="{{ sub_product.image.url }}" alt="{{ sub_product.name }}" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="sub-product-desc glass-card fade-in">
|
||||||
|
<h2>About {{ sub_product.name }}</h2>
|
||||||
|
<p>{{ sub_product.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if articles %}
|
||||||
|
<section aria-labelledby="articles-heading">
|
||||||
|
<h2 class="articles-section-title" id="articles-heading">Articles</h2>
|
||||||
|
<div class="articles-list">
|
||||||
|
{% for article in articles %}
|
||||||
|
<article class="article-card glass-card fade-in" aria-labelledby="article-{{ article.pk }}-title">
|
||||||
|
<h3 class="article-title" id="article-{{ article.pk }}-title">{{ article.title }}</h3>
|
||||||
|
<p class="article-description">{{ article.description }}</p>
|
||||||
|
{% if article.sections.all %}
|
||||||
|
<div class="article-sections">
|
||||||
|
<dl class="article-sections-list">
|
||||||
|
{% for section in article.sections.all %}
|
||||||
|
<div class="article-section-item">
|
||||||
|
<dt class="section-key">{{ section.title }}</dt>
|
||||||
|
<dd class="section-value">{{ section.value }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="sub-product-sidebar" aria-label="Related modules">
|
||||||
|
<div class="sidebar-sticky">
|
||||||
|
<div class="sidebar-section glass-card">
|
||||||
|
<h3 class="sidebar-title">
|
||||||
|
<a href="{{ main_product.get_absolute_url }}">{{ main_product.name }}</a> Modules
|
||||||
|
</h3>
|
||||||
|
{% if siblings %}
|
||||||
|
<ul class="sidebar-list" role="list">
|
||||||
|
{% for sibling in siblings %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ sibling.get_absolute_url }}" class="sidebar-link">
|
||||||
|
<span class="sidebar-dot" aria-hidden="true"></span>
|
||||||
|
{{ sibling.name }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="sidebar-empty">No other modules in this product.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<a href="{{ main_product.get_absolute_url }}" class="btn-ghost sidebar-back-btn">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path d="M10 3L5 8L10 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Back to {{ main_product.name }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user